🐞 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
- 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).
- Read back the dream-written documents together with their
source_ids.
- Resolve every cited id against the documents table — the same lookup
fetch_documents_by_ids
performs.
- 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).
- 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_snippets → format_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:
_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.
source_ids field description - teach the [id:xxx] convention and a copy-exactly rule,
mirroring what delete_observations already does.
- Deduction + induction prompt RULES - state the same rule where the model actually reads it.
_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.
🐞 Bug Report
Describe the bug
Dream specialists cite their evidence via
source_ids, but nothing validates that a cited idrefers to a document that actually exists.
validate_level_fields(src/schemas/internal.py) checks only that the list is non-empty.minItems.create_observationsnormalizes the ids (stripping anid: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_documenttree traversal breaks, and onv3.0.12+ conclusion dating breaks too -
_latest_source_timestamp(added in #890) silently fallsback 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
mainas of 2026-07-26, so there is no version to wait for.To Reproduce
DREAM_ENABLED=trueand let dream specialists write deductive/inductivedocuments. Either wait for a natural cycle or force one with
POST /v3/workspaces/{workspace}/schedule_dream(returns204).source_ids.fetch_documents_by_idsperforms.
this ran at ~7% overall and 15% for the inductive specialist (full table under Additional
context).
validate_level_fields, thetool schema's
minItems, and were persisted normally.The signature case in our data is an inductive document whose
source_idscontained the literalstring:
Expected behaviour
source_idsshould be resolved against real documents before persistence:ObservationFailure,not silently stored. A "deduction" citing nothing real is worse than no deduction.
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
/v3API60a15e66), self-hosted via docker compose. Root-cause sitesalso verified present on
main2026-07-26.Additional context
Root cause. Both specialists are given
search_messages. Its results are raw message text withno id (
_format_message_snippets→format_new_turn_with_timestamp). Observations, bycontrast, render as
[id:xxx]. Induction reasons largely over messages, so the ids it is asked tocite 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_idsfeedsfetch_documents_by_ids, so amessage 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):
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: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:
_format_message_snippets- header states results are raw messages with no id, must not be citedin
source_ids, and belong insources/premisesinstead.source_idsfield description - teach the[id:xxx]convention and a copy-exactly rule,mirroring what
delete_observationsalready does._filter_ungrounded_source_ids, called fromcreate_observations- the guarantee that holdsregardless of model behaviour, implementing the Expected behaviour above.
We set the keep threshold at ≥1 real source to match
validate_level_fieldsrather than the toolschema'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.