Skip to content

[Bug] Dreamer specialists cite source_ids that don't exist. No validation on the write path #939

Description

@redx042

🐞 Bug Report

Describe the bug

Dream specialists cite their evidence via source_ids, but nothing validates that a cited id
refers to a document that actually exists.

  • validate_level_fields (src/schemas/internal.py) checks only that the list is non-empty.
  • The tool JSON schema checks only minItems.
  • create_observations normalizes the ids (stripping an id: prefix) and persists them unchecked.

A fabricated id therefore becomes permanent false provenance that passes every existing check. A
conclusion appears sourced when it is not, get_children_of_document tree traversal breaks, and on
v3.0.12+ conclusion dating breaks too - _latest_source_timestamp (added in #890) silently falls
back to "now" when nothing resolves, so a dangling citation degrades into a wrong timestamp rather
than an error.


Is this a regression?

No. This is a missing validation rather than a behaviour that broke. It appears to have been
present since the dream specialists were introduced. Confirmed on v3.0.11, and both root-cause
sites are byte-identical on main as of 2026-07-26, so there is no version to wait for.


To Reproduce

  1. Self-host Honcho with DREAM_ENABLED=true and let dream specialists write deductive/inductive
    documents. Either wait for a natural cycle or force one with
    POST /v3/workspaces/{workspace}/schedule_dream (returns 204).
  2. Read back the dream-written documents together with their source_ids.
  3. Resolve every cited id against the documents table — the same lookup fetch_documents_by_ids
    performs.
  4. Observe that a consistent fraction of cited ids resolve to no document at all. In our data
    this ran at ~7% overall and 15% for the inductive specialist (full table under Additional
    context
    ).
  5. Note that none of these documents failed validation — they passed validate_level_fields, the
    tool schema's minItems, and were persisted normally.

The signature case in our data is an inductive document whose source_ids contained the literal
string:

"r****: Stage a proposal for the Zig version note..."
  • a formatted message line pasted straight into the id field.

Expected behaviour

source_ids should be resolved against real documents before persistence:

  • Fabricated ids are stripped.
  • An observation left with no real source is rejected and surfaced as an ObservationFailure,
    not silently stored. A "deduction" citing nothing real is worse than no deduction.
  • An observation retaining ≥1 real source is stored with the fabricated ids removed.

Additionally, the specialists should be told that message search results carry no id and must
not be cited, since that is what creates the pressure to invent one (see Additional context).


Media prove

N/A — server-side/REST behaviour, no UI involved. The quantitative evidence is the citation
resolution table under Additional context.


Your environment

  • OS: Ubuntu 24.04.4 LTS (Linux 7.0.0-28-generic x86_64)
  • Browser name and version: N/A — direct REST calls to the /v3 API
  • Honcho Server Version: v3.0.11 (60a15e66), self-hosted via docker compose. Root-cause sites
    also verified present on main 2026-07-26.
  • Honcho Client Version: N/A - no SDK; the consuming agent calls the HTTP API directly

Additional context

Root cause. Both specialists are given search_messages. Its results are raw message text with
no id (_format_message_snippetsformat_new_turn_with_timestamp). Observations, by
contrast, render as [id:xxx]. Induction reasons largely over messages, so the ids it is asked to
cite do not exist anywhere in its context
, while the schema requires it to produce some. The model
fills the gap. This also explains why induction is consistently the worse offender.

Messages are not citable even in principle. source_ids feeds fetch_documents_by_ids, so a
message id would dangle exactly like a fabricated one. Exposing message ids is therefore not the
fix — that was the first direction we considered and it was wrong.

Measured evidence. Every citation in every dream document ever written on one deployment,
resolved against the DB (2026-07-22):

era specialist citations invented rate
local 30B deductive 193 3 1.6%
local 30B inductive 153 23 15.0%
hosted frontier model deductive 614 46 7.5%
hosted frontier model inductive 676 46 6.8%

Overall 7.5% local vs 7.1% hosted. The rate is essentially identical across a local 30B
(Qwen3-30B-A3B) and a hosted frontier model, so this is model-independent - a prompt/schema
contract problem, not a weak-model artifact.

A second pattern appears once invention is blocked. With a guard in place the model still has to
satisfy the tool schema's minItems: 2, and does so by repeating one real id:

"source_ids": ["uhWNamYDRpBW07AQcb2h8", "uhWNamYDRpBW07AQcb2h8", "uhWNamYDRpBW07AQcb2h8"]

This passes a grounding check while being effectively single-sourced. A distinct-count check would
catch it.

What worked for us. Prompt changes alone reduce the rate but cannot guarantee it, so we used
three prompt changes plus one enforcement point:

  1. _format_message_snippets - header states results are raw messages with no id, must not be cited
    in source_ids, and belong in sources/premises instead.
  2. source_ids field description - teach the [id:xxx] convention and a copy-exactly rule,
    mirroring what delete_observations already does.
  3. Deduction + induction prompt RULES - state the same rule where the model actually reads it.
  4. _filter_ungrounded_source_ids, called from create_observations - the guarantee that holds
    regardless of model behaviour, implementing the Expected behaviour above.

We set the keep threshold at ≥1 real source to match validate_level_fields rather than the tool
schema's minItems: 2, deliberately choosing the smaller behaviour change.

Implementation trap worth flagging: collect the ids inside the session block. Our first
version gathered them after the ORM objects had expired and the refresh had no session. Caught by a
unit test before deployment.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions