Admission wrapped every prepare error in Unavailable, so a key the leaf
could not compose reached the client as an unavailable organ. One private
prepare(document, cache) now keys through the leaf first, mapped to
MindRefusal::Document, then encodes; Mind::admit and the derived-write loop
both use it. Unavailable stays for store, cache-encode and decode faults.
Tests: an_unkeyable_document_is_refused_as_a_document (store and receipts
unchanged), prepare_classifies_a_key_refusal_as_a_document_and_encodes_the_rest
(the derived path cannot reach an unkeyable resolution after the per-kind
bound, so the helper is pinned directly), and the #[ignore]
snapshot_admits_and_reads_back, which opens a scratch copy of the live mind
named by HUGINN_MIND_SNAPSHOT, views every stored document back unchanged,
resolves the six stuck findings and withdraws one long-keyed resolution.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Moves the leaf from 73edee5cdaddece8bda9c6c3271e8ddd064337f9 to the
leaf-key-bound merge commit on Epiphany main,
ef9568655e75bb6cb362a038e2739a5ac5e777ee. Cargo.lock follows with that one
line; no other lock line moves.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Lands the live memory-organ branch on main per operator ruling
eureka-substrate:ruling:merge-to-main-first ("Merge to main first"),
cut spec eureka-substrate:cut_spec:cut-merge-huginn-main.r1.
The merged tree differs from the deployed release 9dd6d800 only by the two
docs/eureka-substrate-*.md files already on main; crates/, Cargo.toml and
Cargo.lock are identical to the release. eureka/memory-organ is kept and
receives no further commits.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The id parser bounds locals at 64 too; Faculty lives in Huginn; two
refusal sites map key errors to Unavailable; six findings unresolvable and
39 resolutions unwithdrawable in the live mind; the merges are safe.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Campaign eureka-substrate, the Mind fixes the operator ruled next after
idunn-watchdog. Typed target and rulings live in Eureka's mind.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eureka-state on Windows could not reach the daemon: the cultnet-rs RUDP
client bound 127.0.0.1, and a loopback-bound socket cannot send to
10.77.0.1 (os error 10051). CultLib 8fc74c7 binds the unspecified address
for a remote endpoint. The daemon and the client move together, because
the ack cuts between the revs changed the RUDP handshake.
epiphany-pipeline moves to Epiphany 73edee5, which pins the same CultLib,
so one cultcache-rs is in the graph and the pipeline's CultCache types
stay Huginn's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
--qdrant-url, --ollama-url and --embedding-model are required since Cut 11a;
the example now runs. Point at the gamecult-ops runbook for the Yggdrasil
unit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Four tools 1:1 with the wire (whoami, admit, view, query); the server
fills instance and provenance.tool; config never kills startup; typed
TooLarge, Rejected, Unencodable and InvalidInput errors. The mind now
validates admission provenance (agent, session, tool) by the leaf's
Title rule. Two Soul passes; registered by the operator 2026-09-30.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A request that fails to encode is a defect in this client, not a transport
failure, so it is ClientError::Unencodable, typed Unencodable at the tool,
instead of the retryable Unavailable. A test reports a non-UTF-8
HUGINN_ENDPOINT as such.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
refuse_provenance validated only agent and session, and only for blank and a
handful of line breaks. It now covers agent, session and tool with the leaf's
Title character rule (an alphanumeric character, no control or line-break
character, no bidi control) inside Short's byte bound. The leaf keeps that rule
private, so a differential test holds the copy to a campaign Title. Restores
the stage doc comment that drifted above SHORT_MAX. Multibyte bound test kills
a chars-count mutant.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The source runtime id adds its field name when present, so exact sizes are
searched over both paddings. The attribution test admits a campaign, which
needs its repo stewarded first.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Request sizes step by four (the payload rides as base64), so a request of
exactly the limit is not reachable through the request. The client tests use
the largest request that fits and the smallest that does not; the raw-socket
test tops a message up with a source runtime id to reach the limit and one
byte past it, which pins the limit against the transport's own refusal.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- The client refuses a request whose encoded size exceeds what one RUDP send
carries (65,507 less the 36-byte header and the 6-byte channel id), before
connecting, as ClientError::TooLarge { bytes, limit }. It was reported as
a retryable Unavailable ("Message too long"). The MCP tools surface it as
isError with {"error":"TooLarge","bytes","limit"}.
- Arguments that fail to deserialize are still a tool error, now with the
same typed body as every other one: {"error":"InvalidInput","detail"}.
- A non-UTF-8 EUREKA_INSTANCE or HUGINN_ENDPOINT is reported as not valid
UTF-8 and not as unset.
- Tests: the schema walk asserts no $id or $schema below any tool's root; the
15 s timeout is measured against a silent daemon; the semantic part of a
query is observed on the wire; Internal's and TooLarge's bodies and the
InvalidInput rewrite are pinned; attribution is refused through the MCP
admit; oversize is TooLarge at and one past the limit.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A batch's agent and session were committed as given: "", " ", a 5,000
character string and a string with a line break all became the receipt's
admitting session. The mind now refuses, at A1 before any document is read,
an agent or session that is longer than Short's 200 bytes (FieldBound), blank
after trim, or spread over more than one line (InvalidFormat), so every
writer gets the check. The refusals are the leaf's own, since the fields are
the leaf's Short type; the published wire schema does not change. Short
exposes no validator, so the bound is a constant pinned to the maxLength its
schema publishes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
cargo-mutants on the Cut 13b diff: replacing get_info with Default::default()
survived, and a client that does not see a tools capability does not list tools.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
8041d82 did not build on its own: the moved Inert index names PipelineRef and
the import stayed behind in client.rs.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The eureka-state binary serves whoami, admit, view and query over stdio (rmcp
2.2.0), each one HuginnClient call. EUREKA_INSTANCE and HUGINN_ENDPOINT are
process configuration: the server fills in the instance and provenance.tool, so
input can name neither. Bad configuration never stops startup; whoami reports
it and every other tool refuses with the typed body. A mind's refusal is a
normal result; Unavailable, Rejected and Misconfigured are results with
isError. The query input schema bundles CultLib's selection schema into $defs
(vendored; a test pins it to the cultnet-rs revision).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The daemon half of the client's fixtures (an in-process daemon over a temporary
mind on a loopback port, and the scripted server) becomes tests/common/mod.rs,
so the MCP binary's tests (Cut 13b) drive the same daemon instead of a second
copy of it. No behaviour change; client.rs shrinks by the moved lines.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Soul pass found no blocker; its four findings (whole-call deadline,
answer correlation, Rejected vs Unavailable, disconnect) are fixed at
3dc50ee with mutation-killed tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The timeout now bounds the whole call: one deadline at call start, every wait
gets what remains, so a chunk-by-chunk deferred body cannot outlive it. The
response's message id and operation are checked against the request. An
envelope-level OperationFailure is ClientError::Rejected, distinct from the
transport's Unavailable, so a retry on Unavailable cannot loop on a permanent
protocol mismatch. Every call disconnects its session, success or failure.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
One call carries one HuginnMindRequest to a daemon over CultNet RUDP on the
operation connection and returns the mind's own HuginnMindResponse. Each call
is its own session and nothing survives it. A Deferred answer is fetched on
that session through cultnet-rs's fetch_content, capped at huginn-mind's
MAX_DEFERRED_BODY_BYTES, and decoded, so a caller never sees the deferral.
Refusals are answers. ClientError::Unavailable is the only error and names the
endpoint. The client opens no store and admits nothing.
Tests run a real daemon in-process on a loopback port over a temporary mind,
plus scripted servers for the failures a daemon cannot be made to produce.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MAX_DEFERRED_BODY_BYTES was the daemon's constant, but a client caps its
content fetch at the same number, and the client crate cannot depend on the
daemon. It moves to huginn-mind::wire next to the response schema it bounds;
the daemon imports it, the pin test stays in bodies.rs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
envelope.rs (encode/decode request and response, OperationFailure,
FAILURE_SCHEMA) leaves huginn-daemon for huginn-mind, next to the wire types
it wraps. The daemon imports it and no longer re-exports it. base64 moves
with it; the daemon keeps base64 because serve.rs measures and decodes
payloads. schemars leaves the daemon, whose only user was the envelope's
OperationFailure derive. The two envelope tests run unchanged from their new
home on huginn-mind fixtures.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A search waits behind acknowledged admissions and answers; a recreated
index refuses until refilled; a first build is lag; the sink owns the
search deadline; every search-path call is bounded by it. Soul pass 4:
merge; its two pins landed. Follow-up: an ask-time watermark would stop
sustained admissions from starving searches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two mutants survived Soul pass 4: dropping `self.settle()` from reconcile,
and dropping `!projector.stale()` from take_search. One test each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Losing a collection the worker found whole at start-up is a rebuild. The
mutation that left Build::Unbuilt in place on a matching label survived,
because nothing observed the difference until the collection was lost.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test read the queued search's due moment through the sink's lock right
after asking, but the worker can take the search first: it failed under the
load of cargo-mutants' baseline run. The stamp is pinned without a worker in
the_sink_owns_the_searchs_deadline_and_tells_the_loop, which is the test that
owns it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
S1. serve_search absorbed the inbox and judged pending under one lock and
popped the search under a second, so an admission acknowledged before the
search was asked could be missed. The absorb, the judgement and the take are
now one critical section, and take_search never takes a search while the inbox
is non-empty or anything ahead of it is owed.
S2. The projector carries Build (Unbuilt, Built, Rebuilding). A collection made
again (label mismatch, or lost after it was once built) is Rebuilding until the
entries it owes are written; settle() is the one place that clears it, so a
failure mid-refill does not end the rebuild. Rebuilding reports Reconciling.
unsearchable stays the only refusal predicate.
S3. A mind whose index was never built is Unbuilt: its first build reports
Behind, so the search waits and answers whichever side looked first. Only a
rebuild of an index that exists refuses.
S5. ServeOptions::search_timeout is deleted. The sink owns the deadline
(Backoff::search_deadline, stamped on every search) and the serve loop asks
the daemon for it (IndexSink::search_deadline), so the loop's wait and the
worker's bound are one value. A mind with no identity and documents owed is
Reconciling, so a search is taken and refused rather than left for a spinning
worker.
S4. A test pins that a later call's bound shrinks as the search spends time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The refusal names the pending count; with every document written the count is
zero and the test proved nothing about it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 2 follow-ups F1, F2, F4, F5 on the semantic index.
F1: every call a search makes (identity reads, label read, embed, query) is
bounded by what remains of one Budget, and the queued ticket carries the moment
SEARCH_DEADLINE after it was asked. The ports take the bound per call
(model_identity, describe, search); QUERY_EMBED_TIMEOUT is gone, since the
ticket's deadline is the one number. A hung identity read frees the worker
within the search's time.
F2: one refusal predicate. Behind is lag and no longer refuses; only
Reconciling, Failing and Refused do, for the sink and the projector alike. The
worker absorbs the inbox before taking a search and leaves the search queued
while the writes ahead of it are unfinished, so a client that admits and then
searches reads its own writes. An index being rebuilt is still refused.
F4: the model-changed refusal and the worker's failure answer to queued
searches name the state and the pending count.
F5: a search that fails for its own reasons no longer forgets the
reconciliation; only a changed model or label does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 2 verdict: merge. Follow-ups: the search path's model identity
reads are not bounded by the search deadline, searches are refused on
ordinary live lag, and two refusals omit the state and pending count.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Embedder::embed takes the bound it must answer within. The query embed gets
QUERY_EMBED_TIMEOUT, derived from SEARCH_DEADLINE, which is also the serve
loop's default search timeout. The bulk flush embed keeps 300 s
(BULK_EMBED_TIMEOUT); it does not block serving.
Projector::embed_verified reads the model identity and the collection label,
embeds, re-reads the identity and refuses on change. Search and flush both use
it; the inline copies and step()'s separate identity read are gone. Flush now
also refuses a collection that is no longer this mind's.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The three searches now carry their own text, so the test asserts which two were
embedded, in what order, not only how many.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cargo-mutants on 11b's diff: three timeouts (a drain loop that never settled
under Projector::reconcile -> Ok(()), a dead worker leaving thirty-second
waits to add up past the harness deadline) and three survivors. Now:
- drain is bounded, the status and gate waits are ten seconds, so a mutant
fails inside the harness deadline instead of hanging it;
- the worker's first step is due immediately rather than one interval after
a startup that is itself a verification, and an idle worker is pinned to
one recheck per interval (a recheck that did not move its own deadline
would list the collection in a loop);
- the log line for hits the mind does not hold is a function with a test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Operator ruling 2026-09-30: while the index is rebuilding (a re-pulled model,
a lost collection, Reconciling or Behind) a semantic search refuses with a
typed Unavailable naming the state and the pending count, at the sink and
again in Projector::search, and never answers from a half-filled collection.
Soul's findings on 11b:
- a failed search clears the reconciliation, so whoami stops reporting Current
and the worker verifies again; the idle recheck is an absolute time that
searches no longer push out;
- the queue holds at most SEARCHES_QUEUED_MAX and refuses the rest by name;
the worker serves one search per turn and returns to its writes; the serve
loop takes back a search it timed out (IndexSink::abandon), so a queued one
is never embedded;
- the model identity is read again after the query is embedded, and the
collection's label is re-read before it: a search is one decision;
- a failure report's pending count is refreshed under the lock that takes the
inbox (absorb);
- wait_status has one overall deadline, and the test gate and worker-stop
waits are bounded, so a mutant fails instead of hanging.
Tests pin each rule: ticket uniqueness, searched() returning exactly what the
worker answered, the refuse_searches call site, serving between batches, the
bound, abandonment, the rechecks and the label.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Daemon::unheld called mind.view once per hit, and every call decodes the
whole mind: 3.27 s on the loop thread at 2000 documents and 200 hits, to feed
one log line, and a second judge of whether the mind holds a document. rank
already reads the rows once and is the only judge now: it returns the hits it
found no document for in Ranked { page, dropped }, and Daemon::finish reports
a count and the first five.
Also in rank: candidates are filtered to held documents before they are cut to
the best 200 by score, so an id the mind lacks cannot take a place from one it
holds and select never sees more than it can answer (its own cut is by
ordinal). A semantic selection with descending is refused SelectionInvalid,
as cursor is: the answer's order is the score's and was silently ignored.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The narrowed selection cleared a cursor that semantic_selection has already
refused, and an empty cursor is no cursor to the evaluator; the mutation run
showed the clear could be deleted with every test still green. A test pins
that an empty cursor still ranks.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Qdrant supplies candidate ids and scores and nothing else. Mind::rank drops
every hit the mind does not hold, then lets CultNet's selection decide
membership over the whole mind with its keys narrowed to the candidates, so
in_force and cited mean what they mean in query. Order is score then ordinal;
the page is cut to the selection's limit and top_k; next is never set. A
cursor with semantic, blank text and an out-of-range top_k are refused by name
before anything is embedded. Mind::query loses its semantic parameter and the
"index is not wired" refusal; the published request schema's description of
SemanticQuery follows.
The serve loop never waits on the embedder: a semantic query becomes a Search
held beside its session, the worker embeds (behind the model's query
instruction) and searches between batches, and the loop answers when the
worker has or when ServeOptions::search_timeout passes. A worker that is
Failing or Refused refuses at once, typed Unavailable naming its own error.
VectorIndex gains search; Qdrant's adapter sends no filter.
Model identity is re-read before every flush and before every search: a model
re-pulled under the same dimensions but a new digest forgets the
reconciliation instead of mixing vectors, and the next step rebuilds. An idle
worker forgets the reconciliation every Backoff::recheck (60 s), so a lost
collection is found without a write. Both go through Projector::reconcile,
still the one judge.
Also fixes a race in the worker loop found by the mutation runs: entries were
taken from the inbox under one lock and published as wanted under another, so
whoami could read a stale Current with nothing queued while the worker held
them. Taking, absorbing and publishing are now one critical section.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
huginn-mind::index defines each kind's search text (pure). The daemon gains
Ollama and Qdrant adapters (ureq 3, no TLS) behind Embedder/VectorIndex
ports, and one worker thread whose Projector owns reconcile. Admission
never waits on the index. Reconcile is the only judge of missing points,
and it runs again after any failure. The collection is labelled with the
mind's genesis receipt id and keyed on model identity (name, digest,
dims, text version), never the URL. whoami reports IndexStatus. The live
smoke passed against Qdrant on Yggdrasil and Raven's Ollama
(qwen3-embedding:0.6b, 1024 dims).
Soul closed it after one fix batch. For Cut 11b: every search hit is
joined back through the mind and filtered by in-force; model identity is
re-checked per flush. Noted limit: Ollama truncates at 4096 tokens.
mind 96, daemon 47.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
F1 the 30 s to 10 min backoff curve is pinned by a pure test.
F2 any failed step forgets the reconciliation; the projector keeps every entry
it was given so a lost collection or a changed model is rebuilt and refilled
without a restart.
F3 the collection label carries the mind genesis receipt id; a collection of
another mind with the same instance name is refused and untouched. A mind with
no receipt yet reconciles nothing.
F4 the effective input limit (model context, not 16 KiB) and the Cut 11b join
requirement are recorded in the module docs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The instance name is only a label; two stores may share it. The receipt of the
first admission never changes and digests the first batch.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Mutation survivors from the first cargo-mutants pass; the equivalent ones
(point-id bit shaping, a byte-limit boundary) are left.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
An answer too large for one send becomes HuginnMindResponse::Deferred,
carrying only the reference's CultMeshCdnArtifactManifest (Q-BP2 A). The
client fetches the body over the session it already holds with
cultnet-rs fetch_content (Q-BP1 A) and gets bytes identical to the mind's
answer. Retention is in memory only: 256 MiB counted by distinct chunk,
a sliding 60 s TTL, and LRU eviction. ResponseTooLarge stays the backstop
above 64 MiB. Every echoed field is bounded at 256 bytes, so every reply
fits the window. A deferred envelope carries its answer's status.
Soul closed it after one fix batch. Named limit: there is no per-peer
budget, so a client can evict another client's body mid-fetch. That is
acceptable for trusted WireGuard clients. mind 91, daemon 27.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>