diff --git a/notes/eureka-pipeline-state-cut.md b/notes/eureka-pipeline-state-cut.md index cb3560da..d21b5b35 100644 --- a/notes/eureka-pipeline-state-cut.md +++ b/notes/eureka-pipeline-state-cut.md @@ -3973,64 +3973,869 @@ record (D4); `open_items` and `history` are uncapped (D6, D7). ## Cut 10. `huginn-daemon`: the CultNet surface -- **Repo/branch:** Huginn, same branch. Depends on Cut 9. -- **Deletes first:** none. +Imagination, 2026-09-16, refreshed against the Body after Cut 9 landed and +Q13 was ruled A. Replaces the old Cut 10 section whole. The old section put +the wire types in a binary crate (overturned by Q13 A), copied Odin's +document-server harness (which cannot reply to a request), listed operations +Cut 9 turned into presets, and predated `Mind::{view, query, open_items, +history}`. Every anchor here is a function, type, constant or test **name**, +never a line: a Hands batch is landing in `crates/huginn-mind/src/query.rs`, +`docs.rs` and `tools/eureka-cut9-mutations.psd1` while this is written (the +main tree carried those three modified and a harness sidecar at spec time), +and this cut touches none of those three files. -**Adds: `crates/huginn-daemon`.** Dependencies mirroring Odin's lean set (R8): -`anyhow`, `chrono`, `cultcache-rs`, `cultmesh-rs`, `cultnet-rs`, `fs2`, -`huginn-mind`, `rmp-serde`, `serde`, `signal-hook`. +### Pins -- `src/wire.rs`: `HuginnMindRequest` and `HuginnMindResponse` (D6), as - `DatabaseEntry` documents with derived schemas. -- `src/main.rs`: `parse_options` for `--state-root`, `--idunn-projection`, - `--idunn-anchor`; bind `GAMECULT_IDUNN_CANDIDATE_BIND`; construct the - document server; `signal-hook` for SIGTERM/SIGINT; the `poll_once` loop. -- `src/serve.rs`: `SinkHandle` implementing `CultMeshRudpRawDocumentSink` - (request → `admit`/query → response) and `SnapshotHandle` implementing - `CultMeshRudpSnapshotSource` (read-only snapshot of one instance's mind). +| Repo | Branch | HEAD | State | +|---|---|---|---| +| Huginn | `eureka/memory-organ` | `8fc39b1` | the last commit; the Hands batch above is uncommitted in the tree. This cut lands after it. | +| Epiphany | `codex/eureka-pipeline-state` | `fb18395f` | tree clean; `epiphany-pipeline/src/lib.rs` last moved at `d5a36c2a`, the rev `crates/huginn-mind/Cargo.toml` pins. No Epiphany change in this cut. | +| CultLib | `main` | `47aa7b6` | `git diff --stat a0813c6 47aa7b6 -- packages/cultcache-rs packages/cultnet-rs packages/cultmesh-rs` is **empty**; every source read below is against the checkout Huginn pins, `a0813c6`. | +| Odin | `main` | `5a015d9` | read for the house daemon shape (`crates/odin-daemon/src/main.rs`) and Eve document ids (`crates/odin-core/src/documents.rs`). | +| Idunn | `main` | `5b3f646` | read for the house request/response shape over a hub (`src/host_actuator.rs`, `HostActuatorHub`). | +| gamecult-ops | `main` | `36a466f` | `systemd/epiphany.service`, `runbooks/odin-yggdrasil.md`, `idunn/yggdrasil/bindings/odin.toml.in`, `inventory.md` (17872 still unallocated). | -**Copy Odin's shape deliberately** (R6): the loopback-only candidate bind -assertion, the signal handler with its PID-namespace comment, the bootstrap -activation wait, the heartbeat, and `poll_server` mapping -`CultMeshRudpPollOutcome::ApplicationRejected` to a logged non-fatal. +Body facts this spec rests on, each by source read of the named file or by +the probe at the end: -**Authority map.** +- **`CultMeshRudpDocumentServer` cannot reply to a request.** Its sink is + `CultMeshRudpRawDocumentSink::accept_raw_document(receipt) -> Result<()>`; + a `DocumentPutRaw` is admitted or rejected (`ApplicationRejected`), and the + only bytes that go back are the transport ACK. Reads are `SnapshotRequest` + → `SnapshotResponseRaw` selected by `schema_ids`/`record_keys` only + (`cultmesh-rs/src/rudp_document_server.rs`, `deliver_application_message`). + So D6's "replies go back as `DocumentPutRaw`" has no mechanism, and a + `PipelineQuery` cannot ride a snapshot filter. That server is Odin's shape + because Odin is a catalog; the organ is a service. +- **`CultNetRudpServerHub` is the multi-session request/response substrate.** + One socket, one `CultNetRudpSession` per remote peer, events + `Connected | Frame | Pong | Disconnected` from `receive_event_once`, and + `send_schema_message(&session, &CultNetMessage)` addressed to one session + by `(remote_addr, session_generation)` (`cultnet-rs/src/rudp.rs`, + `CultNetRudpServerHub`). Idunn's `HostActuatorHub::service` is the live + house pattern (`Idunn/src/host_actuator.rs`). The hub drops any packet + whose `connection_id` is not its own (`receive_packet_once`). +- **`cultnet.operation_request.v0` / `cultnet.operation_response.v0` are the + typed request/response envelope**, with C# reference parity + (`CultLib/src/GameCult.Networking/CultNetSchemaMessages.cs`, + `CultNetOperationRequestMessage`/`ResponseMessage`; `CultNetOperationServer.cs` + with `Accepted`/`Rejected` replies and `FailureSchemaId = + "gamecult.cultnet.operation_failure.v1"` carrying `{code, message}`), published + JSON at `CultLib/contracts/cultnet/cultnet.operation-{request,response}.schema.json`, + and named by Eve as its plugin ABI carrier (`Eve/docs/plugin-architecture.md`, + "Executable Plugin ABI"). Fields: `message_id, service_id, operation, + payload_schema, payload_encoding, payload, source_runtime_id?, + target_runtime_id?`; the response adds `status`, `diagnostics`. CultNet + validates `payload_encoding == "messagepack-base64"` and every string + non-empty (`contracts.rs`, `validate_message`). The Rust + `CultNetOperationServer` (`operation_service.rs`) is a single-peer wrapper + over one `CultNetRudpSocketTransportConnection` and nothing in Rust uses + it; the C# one is multi-handler. Not used here. +- **Schema publication over CultNet is `SchemaCatalogRequest` → + `CultNetSchemaRegistry::create_catalog_response`**, registrations carry + `schema_json` (required, hashed canonically), `kind: + WireMessage | DocumentPayload | SharedContract` (`schema_discovery.rs`). + This is the one place JSON crosses the wire, and it is schema publication. +- **A client is `CultMesh::create_rudp_client_for_endpoint(runtime_id, + connection_id, "rudp://host:port", CultMeshRudpSocketOptions)`** then + `connect`, `send_schema_message`, `receive_schema_message_once`, + `poll_resends` (`cultnet-rs/src/cultmesh.rs`, `rudp.rs`). `CultMesh::connect_rudp_client_for_endpoint` + does the connect wait. Cut 13 needs nothing else from the transport. +- **`huginn-mind` at `8fc39b1`:** `Mind::open(state_root, &Slug) -> + Result, MindRefusal>` with + `MindAlreadyOwned` for a held lock (`mind.rs`); `Mind::admit(batch, now)`, + `admit_prepared` and step A1 `if instance != self.instance()` inline in + `admit_steps` (`admission.rs`); `Mind::{view, query, open_items, history}` + (`query.rs`); `PipelineAdmissionBatch` derives only `Clone, Debug, + PartialEq, Eq` under a doc comment claiming the leaf's `PipelineDocument` + cannot serialise, which is false at `d5a36c2a` (Cut 9's finding); + `refusal.rs` still carries the `#[serde(remote = "PipelineRefusal")]` + mirror `DocumentRefusal` and the two `with` attributes on + `MindRefusal::Document`, dead since the leaf derives all three traits; + `store::test_stores::{MemoryStore, RefusingStore}` are `#[cfg(test)] + pub(crate)` and unreachable from another crate; `lib.rs` re-exports the + read and admission types but not `epiphany_pipeline`; the crate reads no + clock and no environment. `Mind` is + `Send` (probe). +- **Odin's daemon** (`odin-daemon/src/main.rs`): `parse_options` over exact + `--state-root --idunn-projection --idunn-anchor`, `GAMECULT_IDUNN_CANDIDATE_BIND` + asserted loopback, `signal_hook::flag::register` for SIGTERM/SIGINT with + the PID-namespace comment, a bootstrap loop `while !try_activate()` gated + on Idunn's process write lease and activation records, a self-presence + heartbeat published through `publish_cultnet_message_to_rudp_catalog`, and + `std::os::fd::{FromRawFd, RawFd}` for systemd-passed signer descriptors — + **Linux-only**. `signal-hook` 0.3 registers SIGTERM/SIGINT on Windows + (probe). +- **`epiphany.service`** spells its endpoints `--qdrant-url`, + `--ollama-base-url`, `--ollama-model` and its Idunn health as + `--idunn-rudp-health 10.77.0.1:17870` (gamecult-ops `systemd/epiphany.service`); + those are Cut 11's and Cut 14's flags and are not added here. +- **Eve's operator surface is a provider advertisement plus surface documents + through Odin**: `gamecult.eve.provider_advertisement.v1` (required: + `providerId, serviceId, verseId, title, kind, freshness, schemas, witnesses, + surfaces, commands`; `Eve/schemas/…provider_advertisement.v1.schema.json`) + and `gamecult.eve.surface_state.v1` (`odin-core/src/documents.rs`, + `EveSurfaceStateRecord { provider_id, title, version, updated_at, surface: + Value }`). Nothing in Rust builds one outside Sleipnir; both carry JSON + `Value` bodies. No Eve DSL type exists in `cultnet-rs` or `cultmesh-rs`. +- **Probe** (detached worktree at `8fc39b1` under the scratchpad, + `CARGO_TARGET_DIR=C:\Users\Meta\.cargo-target-codex`, PowerShell, `cargo + test -p huginn-daemon --lib` once, worktree removed, main tree untouched + by it): with the dependency set under Build budget, a `CultNetRudpServerHub` + on `127.0.0.1:0` accepted two `CultMesh::create_rudp_client_for_endpoint` + clients, received one `OperationRequest` from each carrying a base64 + `rmp_serde::to_vec_named(PipelineQuery)`, answered each on its own session + with an `OperationResponse` carrying a `PipelineQueryPage`, and each client + decoded the reply correlated to its own `message_id`. `schemars::schema_for! + (PipelineQueryPage)` is 42,899 bytes of JSON. Build 30.4 s warm; lock 93 → + 139 entries (41 new package names, listed under Build budget); target dir + 10,619 → 11,030 paths, 8.74 → 9.08 GiB. Probe files kept at + `scratchpad/cut10-probe-lib.rs` and `cut10-probe-Cargo.toml`. -- **Owner:** `huginn-daemon` owns the socket, the process and the lifecycle. It - owns **no** rule: every request becomes a `huginn-mind` call. -- **Inputs:** CultNet `DocumentPutRaw` requests and snapshot queries. -- **Outputs:** `DocumentPutRaw` responses, snapshot responses, presence health. -- **Derived state:** session table, the `pending_index` retry slot (Cut 11). -- **Forbidden writers:** the daemon may not call `cultcache_rs` put/CAS - directly, may not construct a receipt, and may not derive status. -- **Shared paths:** `admit` from Cut 8; the import path in Cut 12. -- **Deletion line:** n/a. +### What the cut does -**Verification.** +The organ's body: one process that opens one instance's mind, serves +admission and the read side over CultNet RUDP to whoever can reach its +socket (the LAN and, through Cut 14's route, WireGuard), refuses to start +when it cannot open the mind, and publishes its own wire schemas on request. -| Test | Pins | -|---|---| -| `request_round_trips_through_the_sink_and_returns_a_typed_outcome` | Wire shape | -| `a_refusal_returns_as_a_response_not_a_transport_error` | Refusals are data | -| `snapshot_source_serves_only_the_named_instance` | Instance isolation on the wire | -| `a_malformed_request_document_is_rejected_without_touching_a_mind` | Store bytes unchanged | -| `foreign_instance_on_the_wire_is_refused` | Ruling 14 across the transport | +- **The wire, in `huginn-mind::wire`** (Q13 A): `HuginnMindRequest` with one + variant per `Mind` method plus `Whoami`; `HuginnMindResponse` with the + method's return type per variant plus `Refused(MindRefusal)` for the read + side; `MindStatus`, the answer to `Whoami` and the typed state a dashboard + would project. All derive `Serialize, Deserialize, JsonSchema`; the payloads + are Cut 8's and Cut 9's types unchanged. +- **The envelope, in `huginn-daemon::envelope`:** a `HuginnMindRequest` rides + `cultnet.operation_request.v0` (`service_id = "huginn.mind"`, `operation` = + the variant's name, `payload_schema = "huginn.mind_request.v1"`, `payload` = + base64 of named MessagePack); the response rides + `cultnet.operation_response.v0` the same way with `status` derived from the + response. An envelope the daemon cannot decode is answered with + `gamecult.cultnet.operation_failure.v1 { code, message }`, status + `rejected`, and touches no mind. +- **The daemon, in `huginn-daemon::daemon`:** `Daemon { mind: + Mind, index: I }` and `handle(&mut self, request, now) -> + HuginnMindResponse`, a pure dispatch with no socket, no clock and no rule: + `Admit` → `mind.admit`; every other instance-bearing request → + `mind.require_instance(&declared)?` then the method; after a `Committed` + outcome, `index.committed(&mind, &writes)`, whose error is logged and never + changes the outcome. +- **The loop, in `huginn-daemon::serve`:** `run(daemon, hub, registry, + stopping)` over `CultNetRudpServerHub`: frames on the `schema` channel are + decoded, answered on their session, and every other message is answered + with `CultNetMessage::Error`; `SchemaCatalogRequest` is answered from the + registry holding the two wire schemas. Hostile datagrams and a departed + session are logged and served past, never fatal. +- **`main`:** `--state-root`, `--instance`, `--bind`; open the mind first, + bind second, serve until SIGTERM/SIGINT. A mind that will not open ends the + process with the refusal on stderr and no socket ever bound. +- **One instance check, one owner:** step A1 becomes `Mind::require_instance`, + called by `admit_steps` as before and by the daemon for reads. The daemon + compares nothing itself. -- **Mutations:** make the sink bypass `admit` and write directly; let the - snapshot source ignore the instance filter. Each fails its test. -- **Builds:** `cargo check -p huginn-daemon --bin huginn-daemon`. -- **Pipeline smoke:** start the daemon on an ephemeral loopback port against a - temp state root, drive one `admit` and one `query` from a CultNet client in - the same test, assert the typed outcomes. This is the "typed handoff between - adjacent organs" tier `F:\Projects\CLAUDE.md` asks for. -- **Process-probe rule:** the smoke spawns only `huginn-daemon` with explicit - argv and an ephemeral port; it never re-launches the test binary. Hands - confirms this by reading the spawn site before running it. -- **Negative grep:** `rg -n "compare_and_swap|put_prepared_batch|HuginnCommitReceipt \{" crates/huginn-daemon/src` - empty. +Not in this cut, by decision: an Eve provider advertisement or surface +(Q23), the Idunn activation handshake and presence heartbeat (Cut 14), +Qdrant and Ollama (Cut 11), a hand-off or import operation (Cut 12 decides +whether it needs one; Findings), and any `.cc` state of the daemon's own. -**Subtraction ledger:** +about 900 lines. +1 binary, +`cultmesh-rs`, -`cultnet-rs`, `signal-hook`, `fs2`. +### What changed against the old Cut 10 section + +1. **The wire types move out of the binary** into `huginn-mind::wire` (Q13 A) + and the daemon gains a lib target so Cut 13 can run it in-process. +2. **`CultNetRudpServerHub` + operation envelopes replace + `CultMeshRudpDocumentServer` + `DocumentPutRaw`/snapshot**, because the + document server has no reply path and a snapshot has no query. `SinkHandle` + and `SnapshotHandle` are not built. +3. **Operations are `Mind`'s methods:** `whoami, admit, view, query, + open_items, history`. D6's `Get` is `view`; `RulingsInForce` and + `Stewardship` are `query` presets (Cut 9); `HandOff` and `Import` are Cut + 12's in-process operations and have no wire form here. +4. **No Odin bootstrap wait, heartbeat, `fs2`, `cultmesh-rs` or + `pending_index`.** The Idunn handshake is Linux-only and Cut 14's; the + index retry slot is Cut 11's; the daemon holds no lease file of its own + (the owned store's lock is the single-writer mechanism, Cut 8). +5. **The smoke spawns no process.** `serve::run` is driven in-process over a + loopback hub; Cut 14's runbook verification exercises the real process. +6. **Two deletions in `huginn-mind`** the map assigned here: the + `DocumentRefusal` mirror, and the false doc on `PipelineAdmissionBatch` + (which now derives the three traits). +7. **Tests 5 → 10, mutations 2 → 16** in `tools/eureka-cut10-mutations.psd1`. + +### Decisions, with the reasons + +### D1. Request/response over CultNet RUDP through the hub; not a CultMesh publication + +Admission is a command with a typed outcome and a query is a filter with a +page; both are request/response. The three CultNet shapes that could carry +them, by source read: + +| Shape | Reply per request | Multi-session | Query filters | Verdict | +|---|---|---|---|---| +| `CultMeshRudpDocumentServer` (D6's) | none from the sink; ACK or rejection | yes | `schema_ids`, `record_keys` only | cannot answer `admit` or `query` | +| `CultNetOperationServer` (Rust) | yes, typed envelope | no: one peer, a new Connect resets the session | any | one workstation session at a time | +| `CultNetRudpServerHub` + `OperationRequest`/`Response` | yes, per session | yes | any | **this** | + +A CultMesh publication path was considered for admission (put a request +document, snapshot the response document later): two round trips and a +response store the daemon would have to own and expire, which is a cache +pretending to be truth. Rejected. Reads through a snapshot source (D6's +"plain CultNet snapshot client can read a mind") are also not built: a +snapshot cannot express `PipelineQuery`, and the raw image is the store's +shape, not the view's (status and admission facts are derived, Cut 9). + +What this buys against doctrine: the organ speaks CultNet (RUDP over UDP, +ruling 19) with CultLib's own typed envelope that the C# reference and Eve's +ABI already speak; JSON crosses the wire only as schema publication (D7). +What it does not buy: a CultMesh state document a dashboard can pull without +speaking the operation envelope. That is Q23. + +### D2. The wire types, exactly + +`crates/huginn-mind/src/wire.rs`, `pub mod wire`, re-exported from `lib.rs`: + +```rust +pub const MIND_SERVICE_ID: &str = "huginn.mind"; +pub const MIND_REQUEST_SCHEMA: &str = "huginn.mind_request.v1"; +pub const MIND_RESPONSE_SCHEMA: &str = "huginn.mind_response.v1"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum HuginnMindRequest { + Whoami, + Admit(PipelineAdmissionBatch), // carries `instance` + View { instance: Slug, id: PipelineRef }, + Query { instance: Slug, query: PipelineQuery }, + OpenItems { instance: Slug, campaign: Slug }, + History { instance: Slug, scope: HistoryScope }, +} +impl HuginnMindRequest { + /// The envelope's `operation`: `whoami | admit | view | query | open_items | history`. + pub fn operation(&self) -> &'static str; + /// The instance the request declares; `Whoami` declares none. + pub fn instance(&self) -> Option<&Slug>; +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub enum HuginnMindResponse { + Whoami(MindStatus), + Admit(PipelineAdmissionOutcome), + View(Option), + Query(PipelineQueryPage), + OpenItems(PipelineOpenItems), + History(Vec), + Refused(MindRefusal), // the read side's refusal +} +impl HuginnMindResponse { + /// The envelope's `status`, the C# reference's vocabulary: `rejected` + /// for `Refused(_)` and `Admit(Refused(_))`, `accepted` otherwise + /// (`Conflict` and `AlreadyAdmitted` are answers, not refusals). + pub fn status(&self) -> &'static str; +} + +/// What a mind says about itself: the typed state a dashboard projects. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct MindStatus { + pub instance: Slug, + pub schema_epoch: String, // PIPELINE_SCHEMA_EPOCH + pub documents: u32, // pipeline documents in the image + pub receipts: u32, +} +impl Mind { pub fn status(&self) -> MindStatus; } // in mind.rs +``` + +`Admit` carries the batch whole because the batch already carries +`instance` and `provenance`; a second `instance` beside it would be two +declarations. The read requests carry `instance` because ruling 14 says a +write carrying another instance's identity is refused *whatever the +transport*, and a read of the wrong mind is the same collision on the read +side (D3). `Whoami` carries none: it is how a client learns which mind it +reached. + +`PipelineAdmissionBatch` gains `Serialize, Deserialize, JsonSchema`; its +doc comment loses the false sentence. `MindRefusal::Document` loses its two +`with` attributes; `DocumentRefusal` is deleted. + +Serialisation: `rmp_serde::to_vec_named` and `rmp_serde::from_slice`, so +field names on the wire are the names the JSON schemas publish (the C# +reference keys by name). `PipelineDocument` is adjacently tagged +`{kind, value}` and rides as a map; the leaf's `[value]` array form is the +*store* shape, not the wire's, and the daemon never sees an envelope. + +Schemas: `schemas/cultnet/huginn.mind_request.v1.schema.json` and +`huginn.mind_response.v1.schema.json` in Huginn, derived by +`schemars::schema_for!` and pinned byte-for-byte by a test (Epiphany's +`pipeline_published_schemas_match_derivation` pattern). They are Huginn's +contracts, published from Huginn (D6's schema-publication paragraph stands). +Expect about 100 KB each; the pipeline value types are inlined by +`schemars` under `$defs` and Epiphany's thirteen files are not copied. + +### D3. One instance check, owned by the mind + +`admit_steps`'s A1 becomes: + +```rust +impl Mind { + /// Ruling 14 across every transport: the declared instance is this + /// mind's, else `ForeignInstance { declared, mind }`. A1 for admission; + /// the daemon asks it for every read that names an instance. + pub fn require_instance(&self, declared: &Slug) -> Result<(), MindRefusal>; +} +``` + +`admit_steps` calls it where the inline comparison was; behaviour identical, +H1 re-anchors to the new body (Per-file changes). The daemon's `handle` +calls it once per instance-bearing read before dispatch and never compares +an instance itself. Rejected: comparing in the daemon (a second validator of +A1, the thing the brief forbids) and dropping the instance from reads (a +client configured for `thought-cage` would silently read `yggdrasil`). + +### D4. The daemon is a pure dispatch over a hub, without a transport trait + +```rust +// daemon.rs +pub trait IndexSink { + /// Called after `Committed`, with the landed refs; Cut 11 reads + /// `mind.view(ref)` per ref and indexes. An error never reaches the caller. + fn committed(&mut self, mind: &Mind, writes: &[PipelineRef]) -> anyhow::Result<()>; +} +pub struct NoIndex; // this cut's implementation +pub struct Daemon> { mind: Mind, index: I } +impl Daemon { + pub fn open(state_root: &Path, instance: &Slug) -> Result; // Mind::open +} +impl> Daemon { + pub fn with_index>(self, index: J) -> Daemon; + pub fn mind(&self) -> &Mind; + pub fn runtime_id(&self) -> String; // "huginn-" + pub fn handle(&mut self, request: HuginnMindRequest, now: DateTime) -> HuginnMindResponse; +} +``` + +`handle`, exactly: `Whoami` → `Whoami(mind.status())`. `Admit(batch)` → +`mind.admit(batch, now)`; if `Committed { writes, .. }`, `index.committed(mind, +&writes)` and on `Err` one `eprintln!` naming the refs; the outcome is +returned as computed before the sink ran. Every other variant → +`mind.require_instance(declared)` then the method, `Err(refusal)` → +`Refused(refusal)`. No `Utc::now()` here: `now` is the caller's, so a test +pins it. + +No `Transport` trait. The loop below is under forty lines; a trait with one +live and one test implementation over it is the one-implementation +abstraction ruling 20 killed in Cut 5. Typed hand-off between organs is +proven without a socket at `handle` (Tests 1-4) and at the envelope codec +(Tests 5-6); the loop is proven once over a loopback hub (Test 9). The +hub is CultLib's port, not ours. + +The daemon's tests open real minds through `Mind::open` in a `tempfile` +directory (redb, milliseconds) rather than promoting `MemoryStore` out of +`cfg(test)`: no feature flag, no `pub` test surface, and the lock is +exercised. If Cut 13's smoke wants a memory mind, one +`#[cfg(any(test, feature = "test-stores"))]` line is the promotion; noted +under Findings, not done. + +### D5. The envelope codec, exactly + +`envelope.rs`, pure functions: + +```rust +pub const FAILURE_SCHEMA: &str = "gamecult.cultnet.operation_failure.v1"; // the C# reference's FailureSchemaId +#[derive(Serialize, Deserialize, JsonSchema, ..)] +pub struct OperationFailure { pub code: String, pub message: String } // keys as the C# type: `code`, `message` + +pub fn encode_request(message_id: &str, request: &HuginnMindRequest, source_runtime_id: Option) -> Result; +pub fn decode_request(message: &CultNetMessage) -> Result<(String /*message_id*/, HuginnMindRequest), OperationFailure>; +pub fn encode_response(message_id: &str, operation: &str, response: &HuginnMindResponse, source_runtime_id: &str) -> Result; +pub fn encode_failure(message_id: &str, operation: &str, failure: &OperationFailure, source_runtime_id: &str) -> CultNetMessage; +pub fn decode_response(message: &CultNetMessage) -> Result<(String, Result)>; // Cut 13's side, tested here for symmetry +``` + +`decode_request` refuses, each with its own `code`, in this order: +`not-an-operation-request` (any other `CultNetMessage`), `wrong-service` +(`service_id != MIND_SERVICE_ID`), `wrong-payload-schema`, `payload-not-base64`, +`payload-not-a-request` (`rmp_serde::from_slice` fails), `operation-mismatch` +(`operation != request.operation()`: the envelope's string is checked against +the enum, never trusted). `payload_encoding` is CultNet's own check +(`validate_message`) and is not re-checked. `source_runtime_id` and +`target_runtime_id` on a request are read by nothing (ruling 18: declared, +not verified, and the declaration is in the payload). `message_id` is the +client's correlation key and is echoed; the daemon keeps no table of them — +the receipt is the organ's idempotency (A9), and a replayed `admit` answers +`AlreadyAdmitted` from the mind, not from a message cache. + +`encode_response`: `status = response.status()`, `payload_schema = +MIND_RESPONSE_SCHEMA`, `diagnostics = []`, `source_runtime_id = +Some(runtime_id)`. `encode_failure`: `status = "rejected"`, `payload_schema = +FAILURE_SCHEMA`, `diagnostics = [failure.code.clone()]`. + +Base64 is `base64::engine::general_purpose::STANDARD` (the crate cultnet-rs +already depends on). Connection id is `cultnet_rs::CULTNET_OPERATION_CONNECTION_ID` +(`0x4355_4c54`), CultLib's constant for operation services; the hub drops +every other connection id, and Cut 13's client must use the same. + +### D6. The loop, exactly + +`serve.rs`: + +```rust +pub struct ServeOptions { pub session_timeout: Duration /* 30 s */, pub idle_sleep: Duration /* 2 ms */ } +pub fn bind(addr: SocketAddr, runtime_id: &str) -> Result; // non-blocking socket; max_fragment_bytes 1200, max_pending_reliable_packets 1024, max_peers 256 +pub fn schema_registry() -> Result; // the two wire schemas, kind WireMessage, wire contract CultNetSchemaV0 +pub fn answer(daemon: &mut Daemon, registry: &CultNetSchemaRegistry, message: CultNetMessage, now: DateTime) -> CultNetMessage; +pub fn run(daemon: &mut Daemon, hub: &mut CultNetRudpServerHub, registry: &CultNetSchemaRegistry, stopping: &AtomicBool, options: &ServeOptions) -> Result<()>; +``` + +`answer`: `OperationRequest` → `decode_request` → `handle(request, +now)` → `encode_response`, or `encode_failure` on a decode failure; +`SchemaCatalogRequest` → `registry.create_catalog_response`; anything else +→ `CultNetMessage::Error { error: "huginn.mind answers cultnet.operation_request.v0 and cultnet.schema_catalog_request.v0" }`. + +`run`: until `stopping`, `hub.remove_timed_out_sessions`, `hub.poll_resends`, +then drain `receive_event_once`: a `Frame` on channel `schema` is decoded +(`decode_cultnet_message_from_slice`, `CultNetSchemaV0`) and its `answer` +sent with `hub.send_schema_message(&session, ..)`; `Connected`, `Pong`, +`Disconnected` are ignored; a frame on another channel is ignored. `Err` +from `receive_event_once` (a hostile datagram fails `decode_rudp_packet`, +or the peer limit) and from `send_schema_message` (a session that left) are +printed and served past. `Utc::now()` is read once per frame here, the +crate's only clock read. Idle loops sleep `idle_sleep`, Odin's shape. + +Stated limits: a socket error and a hostile datagram are indistinguishable +at `receive_event_once` and both are logged, so a dead socket spins with +logging rather than exiting (Odin has the same shape; Cut 14's health is +the observer). The reliable window is `1024 × 1200` bytes per session, about +1.2 MB in flight; a `Query` page of 200 maximal views may exceed it and +`send_many` refuses with an error the loop logs, so the client times out +instead of receiving a typed answer. Soul probes the number; if it is real, +the fix is `PipelineQuery.limit` guidance in Cut 13 or a larger window, not +a second page format. + +### D7. `main`, exactly + +`main.rs`: `parse_options` over exactly `--state-root `, +`--instance `, `--bind `, each required, each once, Odin's +parser shape; then `Daemon::open` (**before** any socket), on `Err(refusal)` +print `refusal` (its `Display`) to stderr and exit 1; then `bind`, +`schema_registry`, `signal_hook::flag::register` for SIGTERM and SIGINT with +Odin's PID-namespace comment (Cut 14 runs this under Idunn), `run`. Nothing +reads the environment: `GAMECULT_IDUNN_CANDIDATE_BIND` and the rest are Cut +14's, which decides how Idunn supplies `--bind`. The mind is dropped at +process exit and the store's lock with it. + +The `--instance` slug is parsed as `Slug` (`epiphany_pipeline`'s newtype, +`From<&str>`); the daemon reaches the leaf only through +`huginn_mind::epiphany_pipeline`, a `pub use epiphany_pipeline;` added to +`huginn-mind`'s `lib.rs` so one crate pins one rev and the daemon and Cut +13's client never declare the git dependency twice. + +### D8. What this cut owns of the operator surface, and what it does not + +Owned here: `MindStatus` (the typed state a dashboard projects), the two +wire schemas on the catalog, and the fact that any CultNet client can call +`whoami`. Not owned here: an Eve provider advertisement or a +`surface_state` composition through Odin. Reasons: the advertisement shape +is a JSON `Value` body with `verseId`, `freshness`, `witnesses` fields that +only mean something once the daemon is a Verse citizen on Yggdrasil beside +Odin (Cut 14), nothing in Rust builds one outside Sleipnir, and the presence +heartbeat it would ride on is Cut 14's. Building it here would be a +dashboard-shaped surface with no dashboard, which `F:\Projects\CLAUDE.md` +names as the thing not to invent. Q23 asks where it lands. + +### Deletes first + +| Path (by name) | Lines | What | +|---|---:|---| +| `crates/huginn-mind/src/refusal.rs`: `enum DocumentRefusal` with its `#[serde(remote = "PipelineRefusal")]`, `#[allow(dead_code)]` and doc comment; the `#[serde(with = "DocumentRefusal")]` and `#[schemars(with = "DocumentRefusal")]` attributes on `MindRefusal::Document` | about 20 | Dead since the leaf derives all three traits at `d5a36c2a`. `MindRefusal::Document(PipelineRefusal)` derives directly. | +| `crates/huginn-mind/src/admission.rs`: the doc sentence on `PipelineAdmissionBatch` beginning `The leaf's `PipelineDocument` derives neither` | 3 | False at the pin. Replaced by "Rides the wire whole (`wire::HuginnMindRequest::Admit`)." | +| `crates/huginn-mind/src/admission.rs`: the inline A1 comparison in `admit_steps` | 3 | Moves into `Mind::require_instance` (D3). | +| `crates/huginn-daemon/src/main.rs`: `fn main() {}` | 1 | The stub. | +| `README.md`, `AGENTS.md`: every sentence saying `huginn-daemon` is a stub, "nothing here publishes yet", "nothing here publishes or connects yet" | about 8 | Describe the live system. | +| The old Cut 10 section's `src/wire.rs` in the binary, `SinkHandle`/`SnapshotHandle`, `cultmesh-rs`, `fs2`, `signal-hook` bootstrap wait and heartbeat, `pending_index` retry slot, `RulingsInForce`/`Stewardship`/`HandOff`/`Import` operations, the snapshot read path, and the spawn-the-binary smoke | — | Not built (What changed, 1-5). | +| The map's D6 and D7 | — | Superseded by D1-D7 here for the transport and by Cut 13's refresh for the tools; the port (`rudp://10.77.0.1:17872`, private range `27880-27887`) and the schema-publication paragraph stand. D8 stands unchanged. Self's edit at landing. | + +Nothing in the leaf, `Cargo.toml` workspace, `Cargo.lock` beyond the +daemon's additions, `query.rs`, `docs.rs`, or the cut-9 entries. + +### Keeps + +Every admission rule and refusal variant; `Mind::get`, `envelopes`, +`envelope`, `receipts`; every test name in `huginn-mind`; H1-H67 with H1 +re-anchored; V1-V23 untouched; the leaf pin `d5a36c2a`; `MemoryStore` and +`RefusingStore` `cfg(test)`; D8's trust boundary (no signer, no anchor, no +credential path: ruling 18). + +### Adds + +| Add | Owner | Live consumer | Protected invariant | Why an existing owner cannot serve | +|---|---|---|---|---| +| `huginn-mind::wire`: `HuginnMindRequest`, `HuginnMindResponse`, `MindStatus`, the three constants, `operation()`, `instance()`, `status()` | `huginn-mind` | `huginn-daemon` (`envelope`, `daemon`), Cut 13's client (Q13 A) | One vocabulary of operations, equal to `Mind`'s methods; refusals are data in the response, never a transport error | Q13 A: the client imports types from `huginn-mind`; a binary crate cannot be imported. | +| `Mind::require_instance`, `Mind::status` (`mind.rs`) | `huginn-mind` | `admit_steps`; `Daemon::handle` | Ruling 14 has one check and it is the mind's; `whoami` is derived from the mind, not from configuration | A1 was inline; the daemon must not restate it. | +| `pub use epiphany_pipeline;` (`lib.rs`) | `huginn-mind` | the daemon's `Slug`, `PipelineRef`; Cut 13 | One git rev of the leaf in the workspace | Two `[dependencies]` entries on one git URL are two places to drift. | +| `schemas/cultnet/huginn.mind_{request,response}.v1.schema.json` + test | Huginn | the catalog response; Cut 13's schema test | The published schema equals the derivation, byte for byte | Huginn's contracts are published from Huginn (D6). | +| `huginn-daemon` `[lib]` `huginn_daemon`: `daemon.rs` (`IndexSink`, `NoIndex`, `Daemon`, `handle`), `envelope.rs` (D5), `serve.rs` (D6) | `huginn-daemon` | `main.rs`; Cut 13's smoke in-process; Cut 11 (`IndexSink`); Cut 14 (`run` under Idunn) | The daemon owns the socket, the process and the envelope, and no rule; the index never decides an outcome; an undecodable envelope touches no mind | Nothing in Huginn serves a socket; Odin's harness is a catalog, not a service. | +| `main.rs` (D7) | `huginn-daemon` | the operator, Cut 14's unit | Refuse loudly before listening (ruling 15) | — | +| `tools/eureka-cut10-mutations.psd1`, D1-D16 | the crate's suite | Epiphany's harness with `-Repo` | every ruling the daemon implements has a revert and a loosening | — | +| Dependencies of `huginn-daemon`: `anyhow`, `base64 = "0.22"`, `chrono = "0.4.44"`, `cultnet-rs` (git `a0813c6…`), `huginn-mind` (path), `rmp-serde = "1"`, `schemars = "1"`, `serde`, `serde_json = "1"`, `signal-hook = "0.3"`; dev `tempfile = "3"` | `huginn-daemon` | as named | — | `cultmesh-rs`, `fs2`, `cultcache-rs` are not needed: the hub and the client live in `cultnet-rs`, the daemon names no store type and holds no lease file. | + +No new document type, kind, format, epoch, `.cc` file, HTTP, JSON on the +wire outside the schema catalog, Qdrant, Ollama, signer or anchor. + +### Per-file changes, by name + +**`crates/huginn-mind/src/refusal.rs`**: the deletes above; module doc's +"the wire (Cut 10) serialises it" stays true. + +**`crates/huginn-mind/src/admission.rs`**: `PipelineAdmissionBatch` derives +`Serialize, Deserialize, JsonSchema`; doc replaced. In `admit_steps`, the A1 +block becomes `self.require_instance(instance)?;` under the same `// A1` +comment. + +**`crates/huginn-mind/src/mind.rs`**: `require_instance` and `status` on +`impl Mind`; `status` counts `image` entries whose type is +a `PipelineKind` type id (`is_known_type` minus the two organ types) and +receipts by `HuginnCommitReceipt::TYPE`; `MindStatus` lives in `wire.rs` +and is imported. + +**`crates/huginn-mind/src/wire.rs`** (new): D2, with a module doc: "The +organ's request and response vocabulary: one operation per `Mind` method +and `whoami`. Payloads are the admission and read types unchanged. The +daemon carries these in CultNet's operation envelope; the client constructs +them. Nothing here validates a document, derives a status or names a +transport." + +**`crates/huginn-mind/src/lib.rs`**: `pub mod wire;`, `pub use wire::{...}`, +`pub use epiphany_pipeline;`; module doc gains one sentence: "The wire +vocabulary is here too, so the daemon and the client share one set of +types." + +**`crates/huginn-mind/src/fixtures.rs`**: unchanged (the batch in flight may +touch it; this cut needs nothing from it). + +**`crates/huginn-daemon/Cargo.toml`**: `[lib] name = "huginn_daemon" path = +"src/lib.rs"`, `[[bin]] name = "huginn-daemon" path = "src/main.rs"`, the +dependencies above, a header comment naming the crate's charter (socket, +process, envelope; no rule). + +**`crates/huginn-daemon/src/lib.rs`**: `pub mod daemon; pub mod envelope; +pub mod serve;` and re-exports. + +**`crates/huginn-daemon/src/{daemon,envelope,serve,main}.rs`**: D4-D7. +Tests beside their module. + +**`schemas/cultnet/`** (new directory in Huginn): the two files; a +`README.md` of five lines saying whose they are and how they are checked. + +**`tools/eureka-cut8-mutations.psd1`**: H1's `File` becomes +`crates/huginn-mind/src/mind.rs` and its `Old` the comparison inside +`require_instance` as Hands lands it (the mutant still `if false`); the +header's `-Target` already lists `mind.rs`. + +**`tools/eureka-cut10-mutations.psd1`** (new): D1-D16 below. + +**`README.md`, `AGENTS.md`**: `huginn-daemon` is live (the CultNet surface: +admission and reads over RUDP, schema catalog, no index yet); `eureka-state` +is the remaining stub; the `cargo` block gains `cargo run -p huginn-daemon -- +--state-root --instance --bind 127.0.0.1:17872`. + +**`Cargo.lock`**: regenerated; +41 package names (Build budget). + +### Authority map + +- **Owner:** `huginn-daemon` owns the socket, the sessions, the process + lifetime and the envelope; `huginn-mind` owns every rule, the instance + check, the receipt, the status and the wire vocabulary. Inside the daemon: + `Daemon::open` owns "may this process serve this mind" (by delegating to + `Mind::open`); `Daemon::handle` owns "which `Mind` method answers this + request"; `envelope` owns "is this a request at all"; `serve::run` owns + "which session gets which reply". +- **Inputs:** `--state-root`, `--instance`, `--bind`; datagrams on one socket; + the wall clock, once per frame, in `serve`. +- **Outputs:** one `OperationResponse` (or failure, or `Error`) per frame on + the frame's session; the schema catalog; the process exit code; stderr. +- **Derived state:** the hub's session table (CultLib's, expired by timeout); + `MindStatus` per `Whoami` call; the schema registry built at start. The + daemon persists nothing of its own. +- **Forbidden writers:** the daemon may not call `compare_and_swap_batch`, + `admit_prepared`, `prepare_entry`, or construct a `HuginnCommitReceipt`; may + not validate a document, derive a status or compare an instance (it calls + `require_instance`); may not read `stored_at`; may not open a second mind + or reopen its mind; may not spool, cache or retry a request; may not read + `source_runtime_id`, `connect_payload` or `remote_addr` for any decision; + may not sign, verify or enrol an identity; may not read the environment. + `IndexSink` may not change an outcome. `huginn-mind` may not name a socket, + a message, or `cultnet_rs`. +- **Shared paths:** `Mind::require_instance` under `admit_steps` and under + every read the daemon dispatches; `Mind::admit` under the wire (this cut), + Cut 12's hand-off (in-process) and Cut 13's tool (through the wire); + `envelope::{encode,decode}_*` under the daemon and Cut 13's client. +- **Deletion line:** the `refusal.rs` mirror and the false doc land in + commit (1) before `wire.rs` exists; `cargo test -p huginn-mind --lib` is + green between commits. + +### Verification + +**Commits.** (1) `huginn-mind`: the two deletes, `require_instance` with +H1 re-anchored, `status`, `PipelineAdmissionBatch` derives, the leaf +re-export; suite green, H1-H67 and V1-V23 killed. (2) `huginn-mind::wire` +and the two schema files with their test. (3) `huginn-daemon` lib: `daemon`, +`envelope`, `serve`, tests. (4) `main.rs`, README/AGENTS. (5) +`eureka-cut10-mutations.psd1`, every entry killed. Soul verifies per +commit; if Hands runs long the split is between (2) and (3), recorded as +10a/10b. + +**Builds.** `CARGO_TARGET_DIR=C:\Users\Meta\.cargo-target-codex` through +PowerShell (the Bash tool collapses the path; Cut 6d's scar); path-list +baseline before and after; **no `cargo clean` of any scope**. `cargo check +-p huginn-mind --lib --tests`; `cargo test -p huginn-mind --lib` (51 + the +batch in flight's count, 0 warnings); `cargo check -p huginn-daemon --lib +--bin huginn-daemon --tests`; `cargo test -p huginn-daemon --lib`; `cargo +check --workspace` (the `eureka-state` stub still builds); `cargo tree -p +huginn-daemon -e normal -d` shows **no duplicate of any GameCult crate** +(one `cultcache-rs`, one `epiphany-pipeline`, one `cultnet-rs`); the only +duplicates are the `windows-sys` majors the lock already carried plus 0.52 +from `socket2` (probe). +Host = target = workstation; the Linux build is Cut 14's. + +**Tests**, `crates/huginn-mind` (new, beside the existing) and +`crates/huginn-daemon`, each named for the rule it pins. Daemon tests open +`Daemon::open(tempdir, "yggdrasil")` and seed with one `instance` document +built inline (`PipelineInstance { instance, display_name, created_at, host }` +through `huginn_mind::epiphany_pipeline`), `PipelineProvenance { faculty: +Faculty::Hands, .. }`, and a fixed `now`. + +| # | Test | Pins | +|---|---|---| +| 1 | `mind::tests::require_instance_is_the_one_check_admission_and_the_daemon_share` (huginn-mind) | D3: `require_instance("thought-cage")` on a `yggdrasil` mind is `ForeignInstance { declared: "thought-cage", mind: "yggdrasil" }`; `admit` with `batch.instance = "thought-cage"` returns the same value; the existing `admission_refuses_a_foreign_instance_whatever_the_transport` still passes. | +| 2 | `wire::tests::the_wire_vocabulary_is_the_minds_methods_and_status_is_derived_from_the_response` (huginn-mind) | D2: `operation()` yields the six names; `instance()` is `None` only for `Whoami`; `status()` is `rejected` exactly for `Refused(_)` and `Admit(Refused(_))`; every variant round-trips through `to_vec_named`/`from_slice`. | +| 3 | `wire::tests::published_wire_schemas_match_derivation` (huginn-mind) | D2: the two files equal `serde_json::to_string_pretty(schema_for!(..))` byte for byte. | +| 4 | `daemon::tests::a_batch_round_trips_typed_through_handle_without_a_socket` | D4: `Admit([instance])` → `Admit(Committed { writes: [instance:self] })`; `Whoami` → `MindStatus { instance: "yggdrasil", documents: 1, receipts: 1 }`; `Query { kinds: [Instance] }` → one view whose `admission.provenance` is the batch's; `View(id)` → `Some`; `OpenItems` → all empty; `History(Repo(..))` → empty. No socket, no clock read (`now` is passed). | +| 5 | `daemon::tests::a_read_or_a_write_naming_another_instance_is_refused_by_the_mind_and_writes_nothing` | Rulings 14 and 18 across the transport: `Query { instance: "thought-cage" }` → `Refused(ForeignInstance)`; `Admit(batch.instance = "thought-cage")` → `Admit(Refused(ForeignInstance))`; `Whoami.documents` unchanged after both. | +| 6 | `daemon::tests::the_index_is_handed_every_landed_write_after_commit_and_never_decides_the_outcome` | D4, Cut 11's seam: a `RecordingIndex` receives exactly `Committed.writes` (derived writes included: a ruling that answers a question hands two refs) and can `mind.view` each at call time; a `FailingIndex` leaves the outcome `Committed` and the documents readable. | +| 7 | `envelope::tests::a_refusal_is_a_typed_response_not_a_transport_failure` | D5: `encode_response(Refused(..))` is an `OperationResponse` with `status: "rejected"`, `payload_schema: MIND_RESPONSE_SCHEMA`, decoding back to the same `HuginnMindResponse`; `Committed` → `accepted`; `AlreadyAdmitted` and `Conflict` → `accepted`. | +| 8 | `envelope::tests::a_malformed_envelope_is_answered_with_a_failure_and_touches_no_mind` | D5: six malformed messages (each code above) through `serve::answer` yield `OperationResponse { status: "rejected", payload_schema: FAILURE_SCHEMA, message_id: }` with the named `code`, and `Whoami.documents` is 0 after all six; a non-operation message yields `CultNetMessage::Error`; a `SchemaCatalogRequest { include_schema_json: true }` yields both schemas whose `content_hash` equals the registry's. | +| 9 | `serve::tests::two_clients_get_their_own_replies_over_loopback` | D6: `bind("127.0.0.1:0")`, `run` on a thread with a stop flag; two `CultMesh::connect_rudp_client_for_endpoint` clients with `CULTNET_OPERATION_CONNECTION_ID`; one sends `Admit`, the other `Query`, each in its own thread; each receives the reply whose `message_id` is its own and decodes it typed; a third client on another connection id receives nothing within the timeout; stop flag → `run` returns and the thread joins. | +| 10 | `daemon::tests::the_daemon_refuses_loudly_when_it_cannot_open_the_mind_and_binds_nothing` | Ruling 15: a first `Daemon::open` holds the lock; a second on the same path is `Err(MindAlreadyOwned)`; a store planted for `yggdrasil` opened as `thought-cage` is `Err(ForeignInstance)`; `Daemon::open`'s signature takes no address (the bind cannot precede it), and `main`'s order is pinned by D12 below. | + +**Negative greps.** + +- `rg -n "compare_and_swap|admit_prepared|prepare_entry|HuginnCommitReceipt \{|stored_at|in_force|closing_resolution|pipeline_key|validate\(" crates/huginn-daemon/src` empty. +- `rg -n "!= self.instance\(\)|== self.instance\(\)|\.instance\(\) [!=]=|instance != |instance == " crates/huginn-daemon/src` empty (the daemon compares no instance). +- `rg -n "require_instance" crates/huginn-mind/src`: the definition, one call in `admit_steps`, tests; `crates/huginn-daemon/src`: one call in `handle`. +- `rg -n "Utc::now|SystemTime::now" crates/huginn-daemon/src`: exactly one, in `serve.rs`; `crates/huginn-mind/src` empty. +- `rg -n "std::env|env::var|GAMECULT_IDUNN" crates/huginn-daemon/src` empty (Cut 14's). +- `rg -n "ServiceIdentitySigner|enroll_service_identity|verify_service_identity|TrustAnchor|source_runtime_id|connect_payload" crates/huginn-daemon/src`: `source_runtime_id` only where the response sets it; nothing else (ruling 18; D8 stands). +- `rg -n "reqwest|qdrant|ollama|http://|serde_json::to_value|CultMeshRudpDocumentServer|DocumentPutRaw|SnapshotRequest" crates/huginn-daemon/src` empty except `SnapshotRequest`, which must not appear either (Cut 14 adds Idunn's presence answer if it needs one; Findings). +- `rg -n "serde_json" crates/huginn-daemon/src`: only in the schema registry build and its test. +- `rg -n "cultnet_rs|cultmesh_rs|UdpSocket" crates/huginn-mind/src` empty. +- `rg -n "serde\(remote|DocumentRefusal" crates/huginn-mind/src` empty. +- `rg -n "epiphany-pipeline" crates/huginn-daemon/Cargo.toml` empty (the leaf is reached through `huginn_mind::epiphany_pipeline`). +- `rg -n "stub" README.md AGENTS.md`: only about `eureka-state`. +- `git diff --stat -- crates/huginn-mind/src/query.rs crates/huginn-mind/src/docs.rs tools/eureka-cut9-mutations.psd1` empty. + +**Mutations, `tools/eureka-cut10-mutations.psd1`.** Run: + +``` +$env:CARGO_TARGET_DIR = 'C:\Users\Meta\.cargo-target-codex' +powershell -File F:\Projects\Epiphany\tools\eureka-mutations.ps1 -Repo F:\Projects\Huginn ` + -Entries tools/eureka-cut10-mutations.psd1 ` + -Target crates/huginn-mind/src/mind.rs,crates/huginn-mind/src/wire.rs,crates/huginn-daemon/src/daemon.rs,crates/huginn-daemon/src/envelope.rs,crates/huginn-daemon/src/serve.rs ` + -Test 'cargo test -p huginn-daemon --lib' +``` + +Entries whose test lives in `huginn-mind` carry their own `Command = 'cargo +test -p huginn-mind --lib'`. Anchors are content Hands lands, matched once. +Each ruling has a **revert** (the rule absent) and, where one exists, a +**loosening**. + +| # | Rule | Revert | Loosening | Killed by | +|---|---|---|---|---| +| D1 | 14: one instance check, the mind's (D3) | `require_instance` body → `Ok(())` (H1's shape, now shared) | compare `declared.0.len()` to `self.instance.0.len()` | test 1 and test 5 (one anchor, two suites, one owner) | +| D2 | 14/18 on the wire: the daemon passes the declared instance through | `handle`: `Admit(mut batch)` → `batch.instance = self.mind.instance().clone()` before `admit` | reads: `require_instance` called only for `Query`, not `View`/`OpenItems`/`History` | test 5 | +| D3 | refusals are data, never a transport failure | `encode_response`: `Refused(_)` → `encode_failure(.., OperationFailure { code: "refused", .. })` | `status()` returns `accepted` for `Refused(_)` | test 7 | +| D4 | an undecodable envelope touches no mind | `decode_request`: on `payload-not-a-request` return `Ok((id, HuginnMindRequest::Whoami))` | `wrong-service` check dropped | test 8 | +| D5 | the operation string is checked, not trusted | `operation-mismatch` check dropped | compare case-insensitively | test 8 (an `Admit` payload under `operation: "query"`) | +| D6 | the index never decides an outcome | `handle`: `index.committed` `Err` → return `Admit(Refused(Unavailable { .. }))` | `index.committed` called before `admit` (the sink then sees refs it cannot `view`) | test 6 | +| D7 | the index sees every landed write | `index.committed(mind, &[])` | pass `writes[..1]` | test 6 (the derived write) | +| D8 | each reply goes to its own session | `run`: send the reply to the first session in `hub.sessions()` | send to every session | test 9 | +| D9 | only the operation connection id is served | `bind`: `CULTNET_OPERATION_CONNECTION_ID` → `CULTMESH_RUDP_DOCUMENT_CATALOG_CONNECTION_ID` | — | test 9 (the third client) | +| D10 | `whoami` is derived from the mind | `status()`: `documents` → `self.image.len()` (counts the epoch record and receipts) | `receipts` → `0` | test 4 | +| D11 | 15: a mind that will not open is refused before anything listens | `Daemon::open`: `Mind::open` `Err` → open an empty `Mind` at a sibling path `-fallback` | `MindAlreadyOwned` mapped to `Ok` after a retry with the lock file deleted | test 10 | +| D12 | 15: bind after open | `main.rs`: `startup(options) -> Result<(Daemon, Hub, Registry)>` is the one place both happen; the mutant moves `bind` above `Daemon::open`. Test 10 holds the mind's lock, calls `startup` with a free port, asserts `Err`, then binds that port itself: under the mutant the hub holds it and the test's bind fails | — | test 10 | +| D13 | the wire vocabulary equals `Mind`'s methods | `operation()`: `History` → `"query"` | — | test 2 | +| D14 | the published schema equals the derivation | the request schema file has one whitespace change | — | test 3 | +| D15 | `AlreadyAdmitted` and `Conflict` are answers | `status()`: `Admit(AlreadyAdmitted { .. })` → `rejected` | — | test 7 | +| D16 | a non-operation message is answered, not dropped | `answer`: the `_ =>` arm returns `Error { error: "" }` (CultNet's `validate_message` refuses the empty string, so the send fails and the client hangs) | — | test 8 (the `Error` text is non-empty and names both accepted messages) | + +Stated limits: the loop's "log and serve past" on a hostile datagram has no +mutant because the hub's error path is CultLib's (Findings); the reliable +window limit is probed, not mutated; the SIGTERM path is not testable in +the harness and is pinned by reading `main`. + +**Operator checks before landing:** Q22 and Q23 below. Q22 has a recommended +default Hands builds under; Q23 changes nothing in this cut whichever way it +goes. + +### Subtraction estimate + +`huginn-mind`: −about 25 (the mirror, the false doc, the inline A1), +about +150 (`wire.rs` 90, `require_instance` and `status` 30, exports 5, doc 5), ++about 90 tests, +2 schema files (about 200 KB of derived JSON, not counted +as source). `huginn-daemon`: +about 520 outside tests (`daemon.rs` 110, +`envelope.rs` 170, `serve.rs` 140, `main.rs` 90, `lib.rs` 10), +about 480 +tests, +1 lib target; the bin target exists as a stub and becomes real. +Entries file about 260. Total about +1,500 with tests and entries against +the old section's +900; the difference is the envelope codec and the +malformed-input surface the old section did not have, and ten tests for +five. + +Liability retired before it shipped: `SinkHandle`/`SnapshotHandle`, a +`cultmesh-rs` and `fs2` dependency, a pending-index slot, a second copy of +Odin's Idunn bootstrap on a platform where it does not compile, a +process-spawning smoke, and four wire operations that were presets or Cut +12's. Retired in the tree: the dead serde mirror and a false doc. + +Kinds, formats, epoch, leaf pin, `.cc` files: zero. + +### Build budget + +- **Packages that compile:** `huginn-mind` (lib + tests), `huginn-daemon` + (lib + bin + tests), `cultnet-rs` and its 40 new transitive packages + (probe: `aead, aes, aes-gcm, base64, base64ct, cipher, const-oid, ctr, + curve25519-dalek(+derive), der, ed25519, ed25519-dalek, fiat-crypto, + ghash, hmac, inout, opaque-debug, pkcs8, polyval, ppv-lite86, rand, + rand_chacha, rand_core, rmpv, rustc_version, semver, signal-hook(+registry), + signature, socket2, spki, subtle, universal-hash, wasi, wasip2, + wit-bindgen, zerocopy(+derive), zeroize`; lock 93 → 139 entries). Most of + their rlibs were warm from Epiphany builds: the probe built in 30 s. +- **Targets:** one lib target new; the bin target exists. Debug only, + workstation host = target, no features, no codegen, no release profile. +- **Footprint:** baseline this pass 10,619 paths, 8.74 GiB under + `C:\Users\Meta\.cargo-target-codex\debug`; the probe left it at 11,030 + paths, 9.08 GiB, and the cut's own build lands on top of that. **Expected + delta for the cut: +100 to +300 paths, +0.1 to +0.3 GiB** beyond the + probe's, all under `debug/`. Drive C: has over 250 GiB free. Hands records + the path list before and after and reports a miss. +- **Retention:** the shared dir is the operator's; nothing is cleaned. + +### Operator questions + +- **Q22. Is the organ's surface request/response over CultNet RUDP, a + CultMesh state publication, or both?** **A. Request/response through + `CultNetRudpServerHub` with `cultnet.operation_request/response.v0` + (recommended; D1).** Admission and typed queries are request/response by + nature; CultLib's envelope has C# parity and Eve names it as the ABI + carrier; the schema catalog is the one JSON surface. **B. CultMesh + publication only:** admission as a document put and reads as snapshots. + Refused above: the document server cannot reply from its sink, a snapshot + cannot carry a query, and a response store would be a cache pretending to + be truth. **C. Both:** A for the operations, plus the daemon publishing + `MindStatus` (and later an Eve surface) as a CultMesh document through + Odin's catalog for dashboards to pull. C is A plus Q23's answer, not a + third transport; it costs Odin registration, which is Cut 14's body. If C + is wanted now, `MindStatus` is already the document, and the publication + is one `publish_cultnet_message_to_rudp_catalog` call on a heartbeat that + Cut 14 owns. +- **Q23. Where does the operator interface land?** Doctrine says an earned + daemon publishes its capabilities as Eve DSL through CultMesh, lowered by + others. **A. Not this cut (recommended).** This cut owns `MindStatus` and + the wire schemas; a `gamecult.eve.provider_advertisement.v1` and a + `surface_state` composition need a Verse identity, freshness and a + heartbeat that exist only once Cut 14 puts the daemon beside Odin on + Yggdrasil. Land it as a named cut after 14 ("Verse presence": the + advertisement, the status surface, the Idunn health it rides with), before + the proof campaign so Cut 16 can read the mind from a dashboard. Cuts 15 + and 16 are skill wiring and the proof; neither is the right owner. **B. + Fold it into Cut 14**, since Odin registration is part of being deployed + on Yggdrasil; the cost is a larger Cut 14 that already carries the Idunn + handshake, the backup and the route. **C. Build the advertisement here** + against a workstation-local Odin: a surface with no dashboard, and a + `verseId` chosen before the Verse exists; not recommended. + +Decisions Self can overturn without an operator: no transport trait (D4); +real redb minds in the daemon's tests rather than a promoted `MemoryStore` +(D4); the failure-schema mirror carried in the daemon (D5); the option +spelling `--state-root --instance --bind` (D7); the base64 cost of the +operation envelope (about a third over raw bytes, accepted as CultNet's +contract). + +### Findings not assignable to this cut + +- **`CultNetRudpServerHub::receive_event_once` propagates a hostile + datagram as `Err`** (`receive_packet_once`: `decode_rudp_packet(&wire)?`) + and the peer limit likewise, so every hub service must treat `Err` as a + discard or die on the first stray packet; Idunn's `HostActuatorHub::service` + propagates it. CultLib's, under the QUIC campaign's owner; a stray datagram + on a mesh port is not hypothetical. +- **The Rust `cultnet-rs` has no `gamecult.cultnet.operation_failure.v1` + type or published schema**, though the C# reference defines + `CultNetOperationServer.FailureSchemaId` and `CultNetOperationFailure + { code, message }`, and `contracts/cultnet/` publishes only the request + and response schemas. This cut carries a two-field mirror with the C# + keys; parity belongs in CultLib. +- **The Rust `CultNetOperationServer` is single-peer and unused in Rust**; + the C# one is a multi-handler dispatcher. Either it grows to match or it + is deleted; not this campaign's. +- **The Idunn activation handshake is a second message family.** Odin + answers Idunn's route challenge as a `SnapshotRequest` for its own + presence record (`exact_self_presence_query`) through the document + server's snapshot source, and publishes presence with + `publish_cultnet_message_to_rudp_catalog`. Under D1 the hub can answer a + `SnapshotRequest` frame with a `SnapshotResponseRaw` (it is one more + `CultNetMessage` on the `schema` channel), so Cut 14 adds an arm to + `serve::answer` rather than a second server; but Cut 14's spec must say + exactly which message Idunn's route driver sends and how a hub-based + service satisfies `route_required = true`. Odin's `main.rs` uses + `std::os::fd` and builds on Linux only; the Idunn arm will be `cfg(unix)` + or Cut 14 must move the signer-descriptor reading behind a port. +- **`gamecult-ops/runbooks/odin-yggdrasil.md` describes the pre-v2 body** + (Compose container, `/srv/odin/current`, `odin.service`) while the + binding is `gamecult.idunn.operator_binding.v2` under `idunn-odin`; the + runbook Cut 14 models on it is modelling a stale one. +- **Cut 12 has no wire operation here.** If the two minds of a hand-off live + in two daemons, Cut 12 needs either a wire `HandOff`/`Import` pair (one + more variant each in `wire.rs`) or an offline tool that opens both stores; + if both minds are stores on one host, `hand_off(&mut from, &mut to, ..)` + is in-process and no wire form exists. Cut 12's refresh decides; the wire + enum widens additively either way. +- **Cut 13's `tool_schemas_equal_the_published_schemas`** compares against + Epiphany's `schemas/cultnet/` (the pipeline documents); it should compare + the tool schemas against Huginn's `schemas/cultnet/huginn.mind_*.v1` (the + operations). And Cut 13 gains a `history` tool (Q17 B's obligation, Cut + 9's finding) and loses `rulings_in_force`/`stewardship` as tools in favour + of presets. Cut 13's refresh. +- **`MemoryStore` is `cfg(test)`-private to `huginn-mind`.** The daemon's + tests do not need it; if Cut 13's in-process smoke wants a mind without a + file, promote `test_stores` behind `#[cfg(any(test, feature = + "test-stores"))]` (one line) in that cut. +- **A `Query` page of 200 maximal views may exceed the hub's reliable + window** (1024 packets × 1200 bytes); `send_many` then errors and the + client times out. Soul measures it against the leaf's bounds; if real, + Cut 13 caps `limit` by default or the hub options grow. Not a second page + format. +- **The wire schemas are large** (`PipelineQueryPage` alone 42.9 KB; the + response schema will inline every pipeline value type). Fine on the + catalog (16 MiB payload cap) and as files; noted so nobody reads the size + as a defect. +- **`Faculty::SelfFaculty`** is the wire spelling clients will see (carried + from Cut 9). +- **`README.md`/`AGENTS.md`** go stale again at Cut 11 and Cut 13; the + "describe the live system" tension recurs until the workspace is whole. + +### Pinned HEADs + +- Huginn `eureka/memory-organ` at `8fc39b1`; at spec time the main tree + carried the concurrent Hands batch uncommitted in + `crates/huginn-mind/src/query.rs`, `docs.rs`, `tools/eureka-cut9-mutations.psd1` + and a `docs.rs.eureka-mutation-original` harness sidecar. This spec + anchors by name and touches none of those files. +- Epiphany `codex/eureka-pipeline-state` at `fb18395f`; leaf at `d5a36c2a`. +- CultLib `main` `47aa7b6`, Rust runtimes identical to `a0813c6`. +- Odin `main` `5a015d9`; Idunn `main` `5b3f646`; gamecult-ops `main` + `36a466f`. +- Probe artifacts: `scratchpad/cut10-wt` (detached at `8fc39b1`, one `cargo + test -p huginn-daemon --lib`, 1 passed in 0.08 s after a 30.4 s build, + removed and pruned); `scratchpad/cut10-probe-lib.rs` and + `cut10-probe-Cargo.toml` kept as evidence. The shared target dir read + 10,619 paths / 8.74 GiB before and 11,030 / 9.08 GiB after; nothing was + cleaned. No file in any repo was written and no `notes/` file was touched. ## Cut 11. Qdrant collections and Ollama embeddings