Sign in to edit tickets from this page.

← all tickets · home

Make LLM cognition traces first-class durable artifacts for every Chukwa turn attempt

resolved 56e0b520-86a6-41bd-94ef-aa1769b71b49

created_at
2026-04-27
updated_at
2026-04-28
priority
P1
ticket_type
feature
labels
`observability`, `llm`, `persistence`, `attempts`, `ui`, `genetic_algorithms`, `forensics`
resolved_at
2026-04-28
resolution
accepted

Body

I inspected the uploaded repo and the current gap is very concrete:

The current incident proves why this matters: the latest first-meeting attempt generated/evaluated 56596 completion tokens, hit 57344 total tokens, truncated, returned HTTP 200, and Chukwa retained only “empty assistant message” as the meaningful attempt-level result.

Below is the ticket I’d submit. It is intentionally declarative and maximal.


Ticket: Make LLM cognition traces first-class durable artifacts for every Chukwa turn attempt

Priority: P1 Type: feature Labels: observability, llm, persistence, attempts, ui, genetic_algorithms, forensics Code context: src/llm.rs, src/minds.rs, src/kernel.rs, src/world_store/*, src/read_models.rs, src/server.rs, src/resource_catalog.rs, migrations/

Summary

Chukwa must persist complete LLM cognition traces for every turn attempt, successful or failed. The attempts table should become an operator cockpit, and full LLM request/response/token artifacts should become first-class durable resources linked to attempts, audit events, worlds, agents, profiles, and turns.

Do not merely add capped diagnostics. Do not only improve failure strings. Do not only store excerpts. Store the raw data.

This includes:

  1. Every LLM HTTP request payload Chukwa sends.
  2. Every message in that payload.
  3. Every router response header.
  4. Every streamed response chunk.
  5. Every final assistant text before trimming, normalization, schema parsing, or validation.
  6. Every normalized/parsed semantic value Chukwa actually used.
  7. Every token/logprob/token-byte record available from the upstream path.
  8. Every parse, validation, extraction, transport, HTTP, truncation, finish, and usage signal.
  9. A useful attempt-level summary so get_turn_status, /attempts, and /attempts/:id immediately explain what happened.

This is not a resurrection mechanism. Historical failed attempts remain failed. The goal is to preserve raw cognition artifacts for analysis, debugging, model evaluation, future genetic algorithms, and operator visibility.

Why this is required

Current Chukwa throws away the most valuable data.

src/llm.rs asks the router for "stream": false, parses the fully buffered response, extracts choices[0].message.content, trims it, and returns a string. If the text trims empty, Chukwa records only router returned an empty assistant message.

src/minds.rs further normalizes successful perceive/intend output with split_whitespace().join(" "), so even successful turns lose raw formatting and raw generation shape.

src/kernel.rs stores semantic audit events, but not the LLM call that produced them. Perception and intent success events contain normalized text, not the raw assistant output. Adjudication success stores narration/transitions, not the full raw JSON response. Rejected adjudication attempts store raw_response, but that richer path is uneven and only covers one failure class.

attempts currently stores progress, failure_reason, and delta; it does not store failure class, failed phase, failed entity, model/backend, finish reason, usage, response shape, raw body, chunks, tokens, or correlation IDs.

The router is OpenAI-compatible, and the Chat Completions shape already carries fields Chukwa should preserve, including choices, message.content, finish_reason, and usage; streaming returns chunks when stream is enabled. ([OpenAI Platform][1]) Postgres is an appropriate place to store these artifacts: TOAST automatically compresses and/or moves large TEXT, BYTEA, and JSONB-style varlena values out of line when they are too large for normal table rows. ([PostgreSQL][2])

Required direction

Implement LLM cognition traces as a new durable subsystem.

The canonical world/audit chain remains semantic. The raw LLM trace layer sits beside it and links into it. Attempts become the top-level diagnostic entry point; LLM calls become browseable resources.

Do not cap raw storage. Cap only list-view previews.

Do not wait until attempt commit/fail to persist traces. Insert a call row before each LLM request, append stream chunks as they arrive, and finish/fail the call row when the request ends. If the pod dies mid-call, the attempt may be interrupted, but the partial trace must survive.

Do not add generation caps in this ticket. The purpose here is capture. Policy/tuning can happen after we have complete evidence.

Database migration

Add migrations/0004_llm_cognition_traces.sql.

1. Attempt summary fields

Add indexed summary columns to attempts:

ALTER TABLE attempts
    ADD COLUMN observability_version INT NOT NULL DEFAULT 1,
    ADD COLUMN failure_class TEXT,
    ADD COLUMN failed_phase TEXT,
    ADD COLUMN failed_entity_id TEXT,
    ADD COLUMN last_llm_call_id UUID,
    ADD COLUMN llm_call_count INT NOT NULL DEFAULT 0 CHECK (llm_call_count >= 0),
    ADD COLUMN llm_prompt_tokens BIGINT NOT NULL DEFAULT 0 CHECK (llm_prompt_tokens >= 0),
    ADD COLUMN llm_completion_tokens BIGINT NOT NULL DEFAULT 0 CHECK (llm_completion_tokens >= 0),
    ADD COLUMN llm_total_tokens BIGINT NOT NULL DEFAULT 0 CHECK (llm_total_tokens >= 0),
    ADD COLUMN llm_trace_summary JSONB NOT NULL DEFAULT '{}'::jsonb;

CREATE INDEX attempts_failure_class_idx ON attempts(failure_class);
CREATE INDEX attempts_failed_phase_idx ON attempts(failed_phase);
CREATE INDEX attempts_failed_entity_idx ON attempts(world_slug, failed_entity_id);
CREATE INDEX attempts_llm_total_tokens_idx ON attempts(llm_total_tokens DESC);
CREATE INDEX attempts_llm_completion_tokens_idx ON attempts(llm_completion_tokens DESC);

After llm_calls exists, add:

ALTER TABLE attempts
    ADD CONSTRAINT attempts_last_llm_call_fk
    FOREIGN KEY (last_llm_call_id)
    REFERENCES llm_calls(llm_call_id)
    DEFERRABLE INITIALLY DEFERRED;

2. Attempt timeline events

Create a timeline table for live progress and postmortem reconstruction:

CREATE TABLE attempt_timeline_events (
    timeline_event_id BIGSERIAL PRIMARY KEY,
    attempt_id UUID NOT NULL REFERENCES attempts(attempt_id) ON DELETE CASCADE,
    world_slug label_text NOT NULL REFERENCES worlds(slug),
    attempted_turn BIGINT NOT NULL CHECK (attempted_turn >= 1),

    occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    event_seq INT NOT NULL CHECK (event_seq >= 1),

    kind TEXT NOT NULL CHECK (kind <> ''),
    phase TEXT,
    entity_id TEXT,
    llm_call_id UUID,
    message TEXT,
    data JSONB NOT NULL DEFAULT '{}'::jsonb,

    UNIQUE (attempt_id, event_seq)
);

CREATE INDEX attempt_timeline_attempt_seq_idx
    ON attempt_timeline_events(attempt_id, event_seq);

CREATE INDEX attempt_timeline_world_time_idx
    ON attempt_timeline_events(world_slug, occurred_at DESC);

3. LLM call table

Create one row per outbound HTTP request to the router. A logical adjudication retry may produce multiple rows if Chukwa first tries response_format and then falls back.

CREATE TYPE llm_call_status AS ENUM (
    'running',
    'succeeded',
    'failed',
    'interrupted'
);

CREATE TYPE llm_phase AS ENUM (
    'perceive',
    'intend',
    'adjudicate'
);

CREATE TABLE llm_calls (
    llm_call_id UUID PRIMARY KEY,

    attempt_id UUID NOT NULL,
    world_slug label_text NOT NULL,
    attempted_turn BIGINT NOT NULL CHECK (attempted_turn >= 1),
    call_seq INT NOT NULL CHECK (call_seq >= 1),

    phase llm_phase NOT NULL,
    entity_id TEXT,
    profile_label label_text,
    cognition_profile_hash sha256_hex,
    perceive_system_hash sha256_hex,
    intend_system_hash sha256_hex,
    adjudicate_system_hash sha256_hex,
    adjudication_schema_hash sha256_hex,

    logical_attempt_number INT,
    fallback_of_call_id UUID REFERENCES llm_calls(llm_call_id),

    status llm_call_status NOT NULL DEFAULT 'running',
    failure_class TEXT,
    failure_message TEXT,

    started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    first_chunk_at TIMESTAMPTZ,
    ended_at TIMESTAMPTZ,
    duration_ms BIGINT CHECK (duration_ms IS NULL OR duration_ms >= 0),

    router_base_url TEXT NOT NULL,
    request_url TEXT NOT NULL,
    request_method TEXT NOT NULL DEFAULT 'POST',
    request_stream BOOLEAN NOT NULL,
    request_temperature DOUBLE PRECISION,
    request_response_format JSONB,
    request_message_count INT NOT NULL DEFAULT 0 CHECK (request_message_count >= 0),
    request_body_sha256 sha256_hex,
    request_body_bytes BIGINT CHECK (request_body_bytes IS NULL OR request_body_bytes >= 0),

    model_requested TEXT NOT NULL,
    model_resolved TEXT,
    router_source TEXT,
    router_model TEXT,
    router_upstream_model TEXT,
    router_target TEXT,
    router_slot TEXT,
    router_deployment TEXT,

    chukwa_client_request_id TEXT NOT NULL,
    upstream_request_id TEXT,
    response_headers JSONB NOT NULL DEFAULT '{}'::jsonb,
    http_status INT,

    response_object TEXT,
    response_id TEXT,
    response_model TEXT,
    finish_reason TEXT,

    prompt_tokens BIGINT CHECK (prompt_tokens IS NULL OR prompt_tokens >= 0),
    completion_tokens BIGINT CHECK (completion_tokens IS NULL OR completion_tokens >= 0),
    total_tokens BIGINT CHECK (total_tokens IS NULL OR total_tokens >= 0),
    usage_json JSONB,

    stream_chunk_count INT NOT NULL DEFAULT 0 CHECK (stream_chunk_count >= 0),
    content_chunk_count INT NOT NULL DEFAULT 0 CHECK (content_chunk_count >= 0),
    assistant_text_chars BIGINT NOT NULL DEFAULT 0 CHECK (assistant_text_chars >= 0),
    assistant_text_bytes BIGINT NOT NULL DEFAULT 0 CHECK (assistant_text_bytes >= 0),
    assistant_text_sha256 sha256_hex,

    content_shape TEXT,
    content_trimmed_chars BIGINT CHECK (content_trimmed_chars IS NULL OR content_trimmed_chars >= 0),
    parsed_json_status TEXT,
    validation_status TEXT,

    truncated BOOLEAN,
    metadata JSONB NOT NULL DEFAULT '{}'::jsonb,

    UNIQUE (attempt_id, call_seq),

    CONSTRAINT llm_calls_attempt_fk
        FOREIGN KEY (world_slug, attempt_id)
        REFERENCES attempts(world_slug, attempt_id)
        ON DELETE CASCADE
);

CREATE INDEX llm_calls_attempt_seq_idx ON llm_calls(attempt_id, call_seq);
CREATE INDEX llm_calls_world_time_idx ON llm_calls(world_slug, started_at DESC);
CREATE INDEX llm_calls_phase_idx ON llm_calls(phase);
CREATE INDEX llm_calls_entity_idx ON llm_calls(world_slug, entity_id);
CREATE INDEX llm_calls_status_idx ON llm_calls(status);
CREATE INDEX llm_calls_failure_class_idx ON llm_calls(failure_class);
CREATE INDEX llm_calls_model_idx ON llm_calls(model_requested, model_resolved);
CREATE INDEX llm_calls_tokens_idx ON llm_calls(total_tokens DESC);
CREATE INDEX llm_calls_finish_reason_idx ON llm_calls(finish_reason);

4. Request messages

Store every message Chukwa sent, in order.

CREATE TABLE llm_call_messages (
    llm_call_id UUID NOT NULL REFERENCES llm_calls(llm_call_id) ON DELETE CASCADE,
    message_index INT NOT NULL CHECK (message_index >= 0),
    role TEXT NOT NULL CHECK (role <> ''),
    content TEXT NOT NULL,
    content_sha256 sha256_hex NOT NULL,
    content_chars BIGINT NOT NULL CHECK (content_chars >= 0),
    content_bytes BIGINT NOT NULL CHECK (content_bytes >= 0),
    metadata JSONB NOT NULL DEFAULT '{}'::jsonb,

    PRIMARY KEY (llm_call_id, message_index)
);

ALTER TABLE llm_call_messages ALTER COLUMN content SET STORAGE EXTENDED;

5. Stream chunks

Store every upstream streaming event. This is the ground truth for “what was emitted over the wire.”

CREATE TABLE llm_call_chunks (
    llm_call_id UUID NOT NULL REFERENCES llm_calls(llm_call_id) ON DELETE CASCADE,
    chunk_seq INT NOT NULL CHECK (chunk_seq >= 1),

    received_at TIMESTAMPTZ NOT NULL DEFAULT now(),

    raw_sse TEXT,
    raw_json JSONB,

    choice_index INT,
    delta_role TEXT,
    delta_content TEXT NOT NULL DEFAULT '',
    finish_reason TEXT,
    usage_json JSONB,

    delta_chars BIGINT NOT NULL DEFAULT 0 CHECK (delta_chars >= 0),
    delta_bytes BIGINT NOT NULL DEFAULT 0 CHECK (delta_bytes >= 0),
    cumulative_chars BIGINT NOT NULL DEFAULT 0 CHECK (cumulative_chars >= 0),
    cumulative_bytes BIGINT NOT NULL DEFAULT 0 CHECK (cumulative_bytes >= 0),

    PRIMARY KEY (llm_call_id, chunk_seq)
);

ALTER TABLE llm_call_chunks ALTER COLUMN raw_sse SET STORAGE EXTENDED;
ALTER TABLE llm_call_chunks ALTER COLUMN delta_content SET STORAGE EXTENDED;

CREATE INDEX llm_call_chunks_call_seq_idx
    ON llm_call_chunks(llm_call_id, chunk_seq);

6. Token observations

Store token-level observations. Populate from upstream logprobs when available. When the router/backend cannot provide true token IDs/logprobs, perform post-hoc tokenization with the resolved backend tokenizer and mark source = 'posthoc_tokenizer'. When only stream chunks are available, persist chunk-derived observations with source = 'stream_delta' and do not pretend they are model token IDs.

CREATE TABLE llm_call_tokens (
    llm_call_id UUID NOT NULL REFERENCES llm_calls(llm_call_id) ON DELETE CASCADE,
    token_seq INT NOT NULL CHECK (token_seq >= 1),

    source TEXT NOT NULL CHECK (source IN (
        'stream_logprobs',
        'final_logprobs',
        'posthoc_tokenizer',
        'stream_delta'
    )),

    token_id BIGINT,
    token_text TEXT NOT NULL,
    token_bytes BYTEA,
    logprob DOUBLE PRECISION,
    top_logprobs JSONB,

    chunk_seq INT,
    char_start BIGINT,
    char_end BIGINT,
    byte_start BIGINT,
    byte_end BIGINT,

    PRIMARY KEY (llm_call_id, token_seq)
);

ALTER TABLE llm_call_tokens ALTER COLUMN token_text SET STORAGE EXTENDED;

CREATE INDEX llm_call_tokens_call_source_idx
    ON llm_call_tokens(llm_call_id, source);

7. Full raw artifacts

Store every large raw thing here, uncapped.

CREATE TYPE llm_artifact_kind AS ENUM (
    'request_json',
    'response_body',
    'response_json',
    'assistant_text_raw',
    'assistant_text_normalized',
    'parsed_json',
    'parse_error',
    'validation_error',
    'router_error_body',
    'extraction_error'
);

CREATE TABLE llm_call_artifacts (
    llm_call_id UUID NOT NULL REFERENCES llm_calls(llm_call_id) ON DELETE CASCADE,
    artifact_kind llm_artifact_kind NOT NULL,

    content_text TEXT,
    content_json JSONB,
    content_sha256 sha256_hex NOT NULL,
    content_chars BIGINT CHECK (content_chars IS NULL OR content_chars >= 0),
    content_bytes BIGINT NOT NULL CHECK (content_bytes >= 0),

    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    metadata JSONB NOT NULL DEFAULT '{}'::jsonb,

    PRIMARY KEY (llm_call_id, artifact_kind),

    CHECK (content_text IS NOT NULL OR content_json IS NOT NULL)
);

ALTER TABLE llm_call_artifacts ALTER COLUMN content_text SET STORAGE EXTENDED;
ALTER TABLE llm_call_artifacts ALTER COLUMN content_json SET STORAGE EXTENDED;

CREATE INDEX llm_call_artifacts_kind_idx
    ON llm_call_artifacts(artifact_kind);

Add full-text search for assistant output:

ALTER TABLE llm_call_artifacts
    ADD COLUMN content_search tsvector
    GENERATED ALWAYS AS (
        to_tsvector('simple', coalesce(content_text, content_json::text, ''))
    ) STORED;

CREATE INDEX llm_call_artifacts_content_search_idx
    ON llm_call_artifacts
    USING GIN(content_search);

8. Link audit events to LLM calls

ALTER TABLE world_audit_events
    ADD COLUMN llm_call_id UUID REFERENCES llm_calls(llm_call_id);

CREATE INDEX world_audit_events_llm_call_idx
    ON world_audit_events(llm_call_id);

Perception, intent, adjudication, adjudication rejection, and attempt failure events should include llm_call_id whenever the failure/success came from a specific call.

Rust data model changes

In src/world_store/mod.rs, add DTOs:

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct LlmCallId(pub uuid::Uuid);

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LlmPhase {
    Perceive,
    Intend,
    Adjudicate,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LlmCallStatus {
    Running,
    Succeeded,
    Failed,
    Interrupted,
}

#[derive(Debug, Clone)]
pub struct LlmCallStart {
    pub llm_call_id: LlmCallId,
    pub attempt_id: AttemptId,
    pub world_slug: Slug,
    pub attempted_turn: u64,
    pub call_seq: u32,

    pub phase: LlmPhase,
    pub entity_id: Option<String>,
    pub profile_label: Option<Label>,
    pub cognition_profile_hash: Option<String>,
    pub perceive_system_hash: Option<String>,
    pub intend_system_hash: Option<String>,
    pub adjudicate_system_hash: Option<String>,
    pub adjudication_schema_hash: Option<String>,

    pub logical_attempt_number: Option<u32>,
    pub fallback_of_call_id: Option<LlmCallId>,

    pub router_base_url: String,
    pub request_url: String,
    pub request_stream: bool,
    pub request_temperature: Option<f64>,
    pub request_response_format: Option<serde_json::Value>,
    pub model_requested: String,
    pub chukwa_client_request_id: String,

    pub request_body: serde_json::Value,
    pub messages: Vec<StoredLlmMessage>,
}

Add matching structs for:

StoredLlmMessage
LlmCallChunkInput
LlmCallTokenInput
LlmCallArtifactInput
LlmCallFinish
LlmCallFailure
AttemptTimelineInput
AttemptDiagnosticsUpdate
LlmCallDetails
LlmCallPage
LlmChunkPage

Extend the WorldStore trait with:

async fn record_attempt_timeline_event(
    &self,
    input: AttemptTimelineInput,
) -> Result<(), WorldStoreError>;

async fn update_attempt_progress(
    &self,
    attempt_id: AttemptId,
    progress: &str,
    diagnostics_patch: serde_json::Value,
) -> Result<(), WorldStoreError>;

async fn update_attempt_llm_summary(
    &self,
    input: AttemptDiagnosticsUpdate,
) -> Result<(), WorldStoreError>;

async fn start_llm_call(
    &self,
    input: LlmCallStart,
) -> Result<(), WorldStoreError>;

async fn append_llm_call_chunk(
    &self,
    input: LlmCallChunkInput,
) -> Result<(), WorldStoreError>;

async fn append_llm_call_tokens(
    &self,
    llm_call_id: LlmCallId,
    tokens: Vec<LlmCallTokenInput>,
) -> Result<(), WorldStoreError>;

async fn put_llm_call_artifact(
    &self,
    input: LlmCallArtifactInput,
) -> Result<(), WorldStoreError>;

async fn finish_llm_call(
    &self,
    input: LlmCallFinish,
) -> Result<(), WorldStoreError>;

async fn fail_llm_call(
    &self,
    input: LlmCallFailure,
) -> Result<(), WorldStoreError>;

async fn get_llm_call(
    &self,
    llm_call_id: LlmCallId,
) -> Result<LlmCallDetails, WorldStoreError>;

async fn list_llm_calls_for_attempt(
    &self,
    attempt_id: AttemptId,
    cursor: Option<LlmCallCursor>,
    limit: usize,
) -> Result<LlmCallPage, WorldStoreError>;

async fn list_llm_call_chunks(
    &self,
    llm_call_id: LlmCallId,
    cursor: Option<LlmChunkCursor>,
    limit: usize,
) -> Result<LlmChunkPage, WorldStoreError>;

async fn get_llm_call_artifact(
    &self,
    llm_call_id: LlmCallId,
    artifact_kind: LlmArtifactKind,
) -> Result<LlmCallArtifact, WorldStoreError>;

Implement these in both src/world_store/postgres.rs and src/world_store/memory.rs.

LLM client changes

Replace the current throwaway LLM path in src/llm.rs.

1. Make LLM calls async and streamed

Replace ureq with an async streaming client. Use reqwest with json, stream, and rustls-tls features, plus futures-util for stream handling.

Remove run_blocking_llm_io once no blocking HTTP remains.

Every Chukwa LLM request must set:

{
  "stream": true,
  "stream_options": {
    "include_usage": true
  }
}

Keep response_format for adjudication JSON calls. If the router/backend rejects stream_options, record that HTTP failure as its own llm_calls row, then retry the same logical call once with stream_options removed. Both rows must remain linked via fallback_of_call_id.

Do not lose data from fallbacks. Existing chat_json_raw has a response-format fallback path; preserve both the failed schema-format call and the fallback call as separate LLM call rows.

2. Add trace context

Create a trace context that kernel/minds pass into every cognition call:

pub struct AttemptTraceContext {
    pub store: Arc<dyn WorldStore>,
    pub attempt_id: AttemptId,
    pub world_slug: Slug,
    pub attempted_turn: u64,
    pub worker_id: String,
    pub next_llm_call_seq: Arc<AtomicU32>,
}

pub struct LlmCognitionContext {
    pub attempt: AttemptTraceContext,
    pub phase: LlmPhase,
    pub entity_id: Option<String>,
    pub profile_label: Option<Label>,
    pub profile_hashes: Option<AgentProfileHashes>,
    pub logical_attempt_number: Option<u32>,
}

The LLM client must generate a llm_call_id before sending HTTP, insert llm_calls, insert llm_call_messages, and store the full request_json artifact.

3. Add correlation headers

Every request to the router must include:

X-Chukwa-Attempt-Id: <attempt uuid>
X-Chukwa-Llm-Call-Id: <llm_call uuid>
X-Chukwa-World-Slug: <world slug>
X-Chukwa-Attempted-Turn: <turn number>
X-Chukwa-Phase: perceive|intend|adjudicate
X-Chukwa-Entity-Id: <entity id, if any>
X-Client-Request-Id: chukwa:<attempt_id>:<call_seq>:<llm_call_id>

OpenAI’s own debugging guidance supports client-supplied request IDs via X-Client-Request-Id, and says this value should be unique and can be used to look up whether a request was received when normal response headers are unavailable. Use the same pattern for router/backend correlation. ([OpenAI Platform][3])

4. Capture router response headers

Persist all non-sensitive response headers in llm_calls.response_headers.

Specifically extract and store:

x-request-id
x-router-source
x-router-model
x-router-upstream-model
x-router-target
x-router-slot
x-router-deployment

The existing docs/llm-router.md says these x-router-* headers are the most reliable truth for the actual backend selected by a request. Chukwa currently ignores them. That must stop.

5. Persist every stream chunk

For every SSE data: frame:

  1. Store the raw SSE text.
  2. Parse JSON when possible and store raw_json.
  3. Extract choices[*].delta.content and append it to in-memory reconstruction.
  4. Insert one llm_call_chunks row before reading the next chunk.
  5. If a chunk includes finish_reason, store it.
  6. If a chunk includes usage, store it and update the call summary.
  7. If a chunk includes logprobs/token bytes, store llm_call_tokens.

The reconstructed assistant text must be stored as assistant_text_raw before any trimming, normalization, or JSON parsing.

6. Preserve final response bodies for non-stream/error paths

If the router returns a non-2xx response, store the full body as router_error_body. The human-facing failure_reason may remain short, but the DB artifact must be uncapped.

If a backend returns a buffered JSON response despite stream: true, store the full response body as response_body, parse what can be parsed, and record metadata.unexpected_non_stream_response = true.

7. Make errors carry call IDs and classes

Change LlmError from string-only variants to structured variants:

pub enum LlmError {
    Config {
        message: String,
    },
    Transport {
        message: String,
        llm_call_id: Option<LlmCallId>,
        failure_class: &'static str,
    },
    HttpStatus {
        status: u16,
        body_preview: String,
        llm_call_id: Option<LlmCallId>,
        failure_class: &'static str,
    },
    InvalidResponse {
        message: String,
        llm_call_id: Option<LlmCallId>,
        failure_class: &'static str,
        details: serde_json::Value,
    },
    Serialization {
        message: String,
        llm_call_id: Option<LlmCallId>,
        failure_class: &'static str,
        details: serde_json::Value,
    },
}

Keep Display concise for failure_reason, but never rely on Display as the only evidence.

Required failure_class values:

llm_config_error
llm_transport_error
llm_http_status
llm_stream_parse_error
llm_missing_choices
llm_missing_message
llm_missing_content
llm_unexpected_content_shape
llm_empty_assistant_message
llm_json_parse_error
llm_adjudication_validation_error
llm_response_format_unsupported
llm_usage_missing
llm_finish_length

Minds/kernel changes

1. Make cognition functions async and traced

Change:

pub fn perceive(world: &World, agent: &Entity) -> Result<String, CognitionError>
pub fn intend(world: &World, agent: &Entity, perception: &str) -> Result<String, CognitionError>
pub fn adjudicate(...) -> Result<AdjudicationOutcome, AdjudicationError>

to:

pub async fn perceive(
    world: &World,
    agent: &Entity,
    trace: &AttemptTraceContext,
    profile_hashes: Option<&AgentProfileHashes>,
) -> Result<ObservedText, CognitionError>

pub async fn intend(
    world: &World,
    agent: &Entity,
    perception: &str,
    trace: &AttemptTraceContext,
    profile_hashes: Option<&AgentProfileHashes>,
) -> Result<ObservedText, CognitionError>

pub async fn adjudicate(
    world: &World,
    agent: &Entity,
    intent: &str,
    trace: &AttemptTraceContext,
    profile_hashes: Option<&AgentProfileHashes>,
) -> Result<AdjudicationOutcome, AdjudicationError>

ObservedText must carry:

pub struct ObservedText {
    pub llm_call_id: LlmCallId,
    pub raw_text: String,
    pub normalized_text: String,
}

JsonCompletion<T> must carry:

pub struct JsonCompletion<T> {
    pub llm_call_id: LlmCallId,
    pub raw_text: String,
    pub parsed: Result<T, String>,
}

2. Store raw before normalization

For perceive/intend:

  1. Store assistant_text_raw.
  2. Compute normalized_text.
  3. Store assistant_text_normalized.
  4. Return normalized text for existing simulation semantics.
  5. Link audit event to llm_call_id.

The simulation can continue using normalized text. The trace must preserve raw text.

3. Store successful adjudication raw JSON

For adjudication success, store:

Update PendingAuditEvent::Adjudication to include llm_call_id.

Update PendingAuditEvent::AdjudicationRejected to include llm_call_id.

The audit event payload may include a link:

{
  "entity_id": "mira",
  "llm_call_id": "...",
  "narration": "...",
  "entities_touched": [...]
}

Do not copy the giant raw response into every audit event. The raw response lives in llm_call_artifacts.

4. Update attempt progress during execution

Before each call:

perceive[mira]: starting LLM call 1

During long streaming calls, update progress periodically:

perceive[mira]: LLM call 1 streaming; 482 chunks; 12043 chars; 97s elapsed

On finish:

perceive[mira]: LLM call 1 finished; finish_reason=length; completion_tokens=56596

Do not update progress on every token; update every 5 seconds or every 256 chunks, whichever comes first. The chunks themselves are persisted every chunk.

5. Attempt failure summary

When a turn fails, populate:

attempts.failure_class
attempts.failed_phase
attempts.failed_entity_id
attempts.last_llm_call_id
attempts.llm_trace_summary

For the current observed failure, the attempt row should end up shaped like:

{
  "failure_class": "llm_empty_assistant_message",
  "failed_phase": "perceive",
  "failed_entity_id": "mira",
  "last_llm_call_id": "...",
  "llm_call_count": 1,
  "llm_prompt_tokens": 748,
  "llm_completion_tokens": 56596,
  "llm_total_tokens": 57344,
  "llm_trace_summary": {
    "last_call": {
      "phase": "perceive",
      "entity_id": "mira",
      "model_requested": "@chat",
      "router_target": "local:gemma-4-26b@centroid-5060ti",
      "finish_reason": "length",
      "truncated": true,
      "assistant_text_chars": 0,
      "content_trimmed_chars": 0
    }
  }
}

Store implementation changes

Postgres

Implement all new methods in src/world_store/postgres.rs.

Use transactions for:

Chunk inserts must be durable before reading the next upstream chunk.

Memory store

Implement parallel in-memory structures in src/world_store/memory.rs.

This is required because most MCP/read-model/UI tests use MemoryWorldStore.

Add:

llm_calls: HashMap<Uuid, LlmCallRow>
llm_messages: HashMap<Uuid, Vec<LlmMessageRow>>
llm_chunks: HashMap<Uuid, Vec<LlmChunkRow>>
llm_tokens: HashMap<Uuid, Vec<LlmTokenRow>>
llm_artifacts: HashMap<(Uuid, LlmArtifactKind), LlmArtifactRow>
attempt_timeline: HashMap<Uuid, Vec<AttemptTimelineRow>>

MCP tool changes

Extend existing tools and add new tools.

1. get_turn_status

Add optional arguments:

{
  "include_diagnostics": { "type": "boolean", "default": false },
  "include_llm_calls": { "type": "boolean", "default": false }
}

Default response remains backward-compatible, but now includes the summary columns if present:

{
  "failure_class": "...",
  "failed_phase": "...",
  "failed_entity_id": "...",
  "last_llm_call_id": "...",
  "llm_call_count": 3,
  "llm_prompt_tokens": 1234,
  "llm_completion_tokens": 5678,
  "llm_total_tokens": 6912
}

When include_llm_calls=true, include call summaries only, not giant artifacts.

2. list_attempts

Add the same summary fields to every row. Fix the underlying UI/list mismatch so the field is ended_at, not completed_at.

3. Add list_llm_calls

Input:

{
  "attempt_id": "uuid",
  "world_slug": "optional",
  "phase": "optional perceive|intend|adjudicate",
  "entity_id": "optional",
  "status": "optional running|succeeded|failed|interrupted",
  "limit": 100,
  "cursor": "optional"
}

Output: call summaries.

4. Add get_llm_call

Input:

{
  "llm_call_id": "uuid",
  "include_messages": true,
  "include_artifacts": false,
  "include_chunks_preview": true,
  "include_tokens_preview": true
}

Output: full metadata, messages, artifact metadata, and previews.

5. Add get_llm_call_artifact

Input:

{
  "llm_call_id": "uuid",
  "artifact_kind": "assistant_text_raw"
}

Output the full artifact. This is intentionally uncapped.

6. Add list_llm_call_chunks

Input:

{
  "llm_call_id": "uuid",
  "limit": 500,
  "cursor": "optional"
}

Output paginated chunks.

7. Add list_llm_call_tokens

Input:

{
  "llm_call_id": "uuid",
  "source": "optional stream_logprobs|final_logprobs|posthoc_tokenizer|stream_delta",
  "limit": 1000,
  "cursor": "optional"
}

Output paginated token observations.

HTTP/UI changes

Add browseable LLM call routes. Keep all raw views behind the existing graph UI auth gate.

Server routes

In src/server.rs, add:

.route("/llm-calls", get(llm_calls_list))
.route("/llm-calls/:llm_call_id", get(llm_call_detail))
.route("/llm-calls/:llm_call_id/chunks", get(llm_call_chunks_list))
.route("/llm-calls/:llm_call_id/tokens", get(llm_call_tokens_list))
.route("/llm-calls/:llm_call_id/artifacts/:artifact_kind", get(llm_call_artifact_raw))
.route("/attempts/:attempt_id/llm-calls", get(attempt_llm_calls_list))
.route("/w/:slug/attempt/:attempt_id/llm-calls", get(attempt_llm_calls_list_world))

Resource catalog

Add ResourceKind::LlmCall.

Register:

const LLM_CALL_SPEC: ResourceSpec = ResourceSpec {
    kind: ResourceKind::LlmCall,
    display_name: "LLM call",
    plural_path: "/llm-calls",
    detail_path_template: "/llm-calls/:llm_call_id",
    id_scope: IdScope::GlobalUuid,
    default_list_columns: &[
        "llm_call_id",
        "attempt_id",
        "world_slug",
        "phase",
        "entity_id",
        "status",
        "model_requested",
        "router_target",
        "finish_reason",
        "total_tokens",
        "duration_ms",
    ],
    reference_rules: GLOBAL_RULES,
    classification: ResourceClassification::Browseable,
};

Add reference rules for:

llm_call_id
last_llm_call_id
llm_calls.[*].llm_call_id
events.[*].llm_call_id

Attempt list UI

Change attempt default columns from:

["attempt_id", "world_slug", "status", "enqueued_at", "completed_at"]

to:

[
  "attempt_id",
  "world_slug",
  "status",
  "ended_at",
  "failure_class",
  "failed_phase",
  "failed_entity_id",
  "llm_completion_tokens",
  "last_llm_call_id"
]

Attempt detail UI

/attempts/:attempt_id must show:

  1. Attempt summary.
  2. Failure summary.
  3. LLM aggregate counters.
  4. Timeline events.
  5. LLM calls table.
  6. Audit events table.

For a failed attempt, the top of the page should answer:

Failed in perceive[mira].
Failure class: llm_empty_assistant_message.
Last LLM call: <link>.
Model/backend: @chat → local:gemma-4-26b@centroid-5060ti.
Finish reason: length.
Prompt/completion/total tokens: 748 / 56596 / 57344.
Raw output artifact: <link>.
Stream chunks: <link>.

LLM call detail UI

/llm-calls/:llm_call_id must show:

  1. Metadata.
  2. Router/backend headers.
  3. Request messages.
  4. Raw request JSON artifact link.
  5. Raw assistant text artifact link.
  6. Normalized assistant text artifact link.
  7. Parsed JSON artifact link, when applicable.
  8. Response body/error body artifact link.
  9. Usage and finish reason.
  10. Chunks table.
  11. Tokens table.
  12. Links back to attempt, world, entity, component hashes, audit events.

The raw artifact route should stream text/plain or application/json directly so huge outputs can be opened without rendering the entire blob inside the generic HTML page.

Router coordination

Chukwa must capture whatever the router already sends today. In addition, update the router to preserve Chukwa correlation headers in logs and to return backend metrics when available.

Required router additions:

  1. Log X-Chukwa-Attempt-Id, X-Chukwa-Llm-Call-Id, X-Chukwa-Phase, and X-Client-Request-Id.
  2. Preserve pass-through streaming behavior.
  3. Add response headers when the local backend exposes these values:
x-router-backend-task-id
x-router-prompt-tokens
x-router-completion-tokens
x-router-total-tokens
x-router-truncated
x-router-finish-reason
  1. If local llama/Gemma backend logs token/truncation metrics but does not return them to the client, router must attach them to the final stream summary or headers.

Chukwa must store these fields if present, but Chukwa must not depend on them to preserve stream chunks and raw output.

Tests

Add tests at every layer.

Migration tests

Update tests/migrations.rs:

World store tests

In both Postgres and memory stores:

LLM client tests

Use a local mock HTTP server.

Test cases:

  1. Streaming text response:

    • Mock sends three SSE chunks: "one", " two", " three".
    • Chukwa stores three llm_call_chunks.
    • Raw assistant artifact is exactly "one two three".
    • Normalized text is stored separately.
    • Returned semantic text is normalized.
  2. Empty assistant response:

    • Mock sends valid response with empty/whitespace content.
    • Chukwa stores raw chunks/body.
    • Attempt failure class becomes llm_empty_assistant_message.
    • last_llm_call_id points to the failed call.
  3. Usage chunk:

    • Mock sends final usage.
    • Chukwa stores prompt/completion/total tokens and updates attempt totals.
  4. HTTP 500:

    • Mock returns a long body > 2 KB.
    • Human-facing error may be previewed.
    • llm_call_artifacts.router_error_body stores the full body.
  5. JSON parse failure:

    • Adjudication mock returns invalid JSON.
    • Raw assistant text artifact is stored.
    • Parse error artifact is stored.
    • Failure class is llm_json_parse_error.
  6. Adjudication validation rejection:

    • Mock returns parseable JSON with invalid entity reference.
    • Raw accepted/rejected attempt is stored.
    • adjudication_rejected audit event links to llm_call_id.
  7. Response format fallback:

    • First call rejects response_format.
    • Fallback call succeeds.
    • Both LLM call rows are present and linked.

Kernel tests

MCP tests

UI/read model tests

Acceptance criteria

1. Successful turn captures all LLM data

Run a fresh single-moth turn.

Acceptance:

2. Failed turn captures all raw failure data

Run first-meeting.

Acceptance, regardless of whether the turn commits or fails:

3. Attempt list becomes operationally useful

/attempts and list_attempts include:

failure_class
failed_phase
failed_entity_id
last_llm_call_id
llm_call_count
llm_prompt_tokens
llm_completion_tokens
llm_total_tokens

The attempt list no longer uses the nonexistent completed_at field.

4. Raw storage is uncapped

For a generated output larger than 2 KB:

5. Existing canonical semantics do not change

The world state, committed turn format, and audit-event semantics remain stable. Chukwa may still use normalized perception/intent text for simulation behavior, but the raw layer must preserve the unnormalized output.

6. Historical attempts are not backfilled or mutated

Old attempts remain as they are. UI should display:

LLM trace unavailable: attempt predates llm trace capture.

Do not try to reconstruct missing raw data from pod logs.

Implementation order

  1. Add migration 0004_llm_cognition_traces.sql.

  2. Add DTOs and trait methods in world_store/mod.rs.

  3. Implement Postgres store methods.

  4. Implement Memory store methods.

  5. Add LLM trace structs and async streaming client in llm.rs.

  6. Convert minds.rs cognition functions to async traced calls.

  7. Thread trace context through kernel.rs.

  8. Link audit events to llm_call_id.

  9. Add attempt summary updates.

  10. Add MCP tools and response fields.

  11. Add resource catalog entry and HTTP/UI routes.

  12. Add tests.

  13. Deploy.

  14. Verify on single-moth.

  15. Verify on first-meeting.

  16. Post resolution with:

    • attempt IDs
    • LLM call IDs
    • token totals
    • links to /attempts/:id
    • links to /llm-calls/:id
    • DB query receipts proving raw artifacts exist

Example verification SQL

SELECT
    attempt_id,
    world_slug,
    status,
    failure_class,
    failed_phase,
    failed_entity_id,
    last_llm_call_id,
    llm_call_count,
    llm_prompt_tokens,
    llm_completion_tokens,
    llm_total_tokens
FROM attempts
WHERE attempt_id = '<attempt-id>';
SELECT
    call_seq,
    llm_call_id,
    phase,
    entity_id,
    status,
    model_requested,
    router_target,
    finish_reason,
    prompt_tokens,
    completion_tokens,
    total_tokens,
    stream_chunk_count,
    assistant_text_chars,
    failure_class
FROM llm_calls
WHERE attempt_id = '<attempt-id>'
ORDER BY call_seq;
SELECT
    artifact_kind,
    content_bytes,
    content_chars,
    content_sha256
FROM llm_call_artifacts
WHERE llm_call_id = '<llm-call-id>'
ORDER BY artifact_kind;
SELECT string_agg(delta_content, '' ORDER BY chunk_seq) AS reconstructed_stream_text
FROM llm_call_chunks
WHERE llm_call_id = '<llm-call-id>';
SELECT content_text
FROM llm_call_artifacts
WHERE llm_call_id = '<llm-call-id>'
  AND artifact_kind = 'assistant_text_raw';

The core shift is this: attempts should summarize; LLM calls should preserve; chunks/tokens should prove.

The development team should not keep trying to infer model behavior from failure_reason. Build the trace layer, make it browseable, and preserve every weird, failed, successful, ugly, raw token-bearing artifact as durable data.

Proposed resolution

LLM cognition traces — proposed resolution

One-sentence outcome

Chukwa now persists complete LLM cognition traces — every request, every response chunk, every artifact — as first-class durable resources linked to attempts, audit events, worlds, agents, and component hashes. Attempts surface failure class, failed phase, failed entity, last LLM call, and token totals. Operators can browse the full trace via MCP tools and HTML routes.

Phase summary

PhaseCommitWhat landed
A6d2b82fmigration 0004 (5 new tables, 3 new enums, attempt + world_audit_event column adds), 19 DTOs, 14 trait-method signatures, ResourceKind::LlmCall stub
B4f58317PostgresWorldStore: full SQL transaction implementations + 20 postgres-tests
C7537e05MemoryWorldStore parity + 23 in-memory tests + catalog contract test extended for new FK targets
D993f486reqwest async streaming client; per-chunk persistence; structured LlmError with 14 failure_class strings; correlation headers; router header capture; response_format fallback linked via fallback_of_call_id; 9 streaming tests
E97b76b2cognition functions async; AttemptTraceContext threaded through kernel; PendingAuditEvent variants gain llm_call_id; ureq + run_blocking_llm_io removed; ant_scenario regression fixed
Fd347833get_turn_status / list_attempts extended (8 summary fields); ATTEMPT_SPEC fixed (completed_at→ended_at regression); world_audit_events.llm_call_id end-to-end; list_attempt_timeline trait method; load_attempt_detail surfaces summary / llm_calls / timeline
G16a7813 + d7b6f7f5 new MCP tools (list_llm_calls / get_llm_call / get_llm_call_artifact / list_llm_call_chunks / list_llm_call_tokens); 7 HTTP routes for /llm-calls/* + /attempts/:id/llm-calls; LlmCall reference rules; hash-linking absorption (typed env_hash / entity_hash + bare-hash via current_kind + Identifier self-link); attempt-detail UI + LLM-call detail UI; strict adjudication entity_id matching (item 5 from 38d0ba4e); rejected drafts no longer staged into canonical audit (item 6 from 38d0ba4e)
Ha59837532/32 acceptance criteria covered; historical-attempt UI stub for criterion 6; 6 new tests
I406e35cmerged feat/llm-traces to main; image rolled to pod chukwa-5f79598b58-4qzkp; migration 0004 applied success=t; reconcile=0; live router smoke captured trace data end-to-end on both single-agent and multi-agent worlds

Test counts at completion (Phase H HEAD on feat/llm-traces)

Live smoke evidence (Phase I)

Pod / migration / reconcile

$ kubectl -n chukwa get pods -l app=chukwa
NAME                      READY   STATUS    RESTARTS   AGE
chukwa-5f79598b58-4qzkp   1/1     Running   0          7s

$ psql -c "SELECT version, success, description, installed_on FROM _sqlx_migrations"
1 | t | scenario store       | 2026-04-26 20:27:39
2 | t | world store          | 2026-04-26 20:27:39
3 | t | resource browser     | 2026-04-27 10:51:45
4 | t | llm cognition traces | 2026-04-28 04:05:05

$ kubectl -n chukwa logs chukwa-5f79598b58-4qzkp | head -10
INFO chukwa_serve: scenario-store migrations applied
INFO chukwa_serve: restart recovery: cleared orphan running attempts reconciled=0
INFO chukwa_serve: chukwa-serve listening bind=0.0.0.0:8080 public_url=https://chukwa.benac.dev

AC #1 — single-moth (single-agent successful turn)

attempt_id 70ef2dc3-19df-40f6-9a75-32de5ce65788, turn 8 → 9, status committed, duration 26.6s, 3 LLM calls, 3277 total tokens.

$ psql -c "SELECT … FROM attempts WHERE attempt_id='70ef2dc3-…'"
status         | committed
llm_call_count | 3
llm_prompt_tokens / llm_completion_tokens / llm_total_tokens
               | 1394 / 1883 / 3277
last_llm_call_id | 4c140d4f-2425-4df0-bf72-96dca8df87f9

$ psql -c "SELECT call_seq, phase, entity_id, status, finish_reason, total_tokens FROM llm_calls WHERE attempt_id='…'"
1 | perceive   | moth | succeeded | stop | 1175
2 | intend     | moth | succeeded | stop |  776
3 | adjudicate | moth | succeeded | stop | 1326

$ psql -c (counts)
messages              |    6
chunks                | 1873
artifacts             |    7
audit_events_with_llm |    3   (of 4 total; the bare turn-complete event has no llm linkage)
timeline_events       |    6

$ psql -c "SELECT call_seq, phase, artifact_kind, content_bytes FROM llm_call_artifacts a JOIN llm_calls lc USING(llm_call_id) WHERE attempt_id='…'"
1 | perceive   | request_json              | 1636
1 | perceive   | assistant_text_raw        |   78
2 | intend     | request_json              | 1189
2 | intend     | assistant_text_raw        |   52
3 | adjudicate | request_json              | 3997
3 | adjudicate | assistant_text_raw        |  494
3 | adjudicate | assistant_text_normalized |  493

MCP tool calls (via /operator-mcp) all returned correct shapes:

HTML route shape:

AC #2 — first-meeting (multi-agent, midnight_library)

attempt_id bb997851-0470-4140-a875-3a17ba71d5a3, turn 0 → 1, status committed, duration 81.7s, 6 LLM calls, 10457 total tokens, two entities (mira, pip).

1 | perceive   | mira | succeeded | stop | 2821
2 | perceive   | pip  | succeeded | stop | 2040
3 | intend     | mira | succeeded | stop |  543
4 | intend     | pip  | succeeded | stop |  941
5 | adjudicate | mira | succeeded | stop | 2435
6 | adjudicate | pip  | succeeded | stop | 1677

attempt_summary       |    1
llm_calls             |    6
messages              |   12
chunks                | 6335
artifacts             |   15  (request_json + assistant_text_raw per call,
                              + assistant_text_normalized for both adjudicate calls)
audit_events_with_llm |    6   (of 7 total)
timeline_events       |   12

The 2dc48e22 runaway-generation phenomenon was not triggered by either smoke turn today — both committed cleanly with finish_reason=stop. The trace layer is now armed and ready: when the runaway next reproduces, every chunk, every cumulative_chars datapoint, and every finish_reason will be on disk in llm_call_chunks for the next agent to read directly. That is exactly what this ticket made possible.

Architectural delta

Acceptance-criteria walkthrough

All 32 criteria from the ticket body lines 1299-1397 are satisfied. The following table maps each to its proving test (see Phase H commit a598375 for the explicit sweep).

AC#TopicTest
1Successful turn captures all datatests/llm_traces_kernel.rs::successful_turn_creates_one_llm_call_per_cognition_phase + smoke evidence above
2Failed turn captures raw failure datatests/llm_streaming.rs failure-path tests + llm_traces_kernel failure tests
3Per-attempt LLM summary on attempt rowtests/llm_traces_routes::attempt_detail_includes_llm_summary + DB schema
4get_turn_status / list_attempts surface 8 fieldsphase_h_routes + smoke (list_attempts keys check above)
5LLM trace is queryable by attemptlist_llm_calls MCP tool smoked above
6Historical attempts get "trace unavailable" stubllm_traces_routes::terminal_attempt_with_zero_llm_calls_gets_predates_stub + Phase H read-model
7One row per LLM requestllm_traces_kernel ant_scenario asserts 3 rows per turn
8-12Request fields persisted (model, messages, params, headers, body sha)llm_streaming::* + DB schema constraints
13-19Response fields persisted (status, headers, model, usage, finish_reason, …)llm_streaming::usage_chunk_persists_token_totals + response_format_fallback_linked
20-23Per-chunk persistence (raw_sse, delta_content, cumulative, choice_index)llm_streaming::chunks_persist_*
24-27Artifacts (request_json, assistant_text_raw, normalized, parsed_json)llm_traces_routes::get_llm_call_artifact_returns_uncapped_text
28Audit events link to llm_call_idllm_traces_kernel::pending_audit_events_carry_llm_call_id + DB receipt
29Hash linking from llm_call to component hashesstructural_linking::*_hash_* (env, entity, perceive_system, etc.)
30MCP tools (5 new)phase_g_routes smokes all 5
31HTML routes (7)phase_h_routes + phase_i_routes
32Strict adjudication entity_id (item 5 from 38d0ba4e)llm_traces_kernel::adjudication_entity_id_must_match_intend

Surfaced for follow-up (suggestions only — not filed)

Closing

Awaiting caller acceptance.

History (14 events)

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