Compare commits

..
Author SHA1 Message Date
6d6aea7059 Merge pull request 'mind-self-run: redact URL userinfo from Self result text' (#50) from eureka-body/self-run-redact-url-userinfo into main 2026-11-11 14:21:27 +00:00
4e4f3a7f3b redact: treat git+https and ws/wss bare userinfo as a token
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 14:21:14 +00:00
08afef830f redact: read the authority as URL parsers do
Userinfo is everything before the last @ of the authority, which ends at / ? # backslash, quotes,
brackets, angle brackets or ASCII whitespace; a path @ (github.com/@user, pkg@1.2.3, a//b@c) is left
alone, non-ASCII stays inside userinfo. Protocol-relative // opens an authority only at a word start.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 14:15:12 +00:00
f4951db649 Merge pull request 'mind-stream: the Projector is her own connector caller on Haiku; a brake hold stops mind-stream@ too' (#47) from hands/eureka-body-stream-projector-kit into main 2026-11-11 14:13:51 +00:00
b19e57a6d3 redact: only ASCII whitespace ends a candidate run
NBSP, U+2028 and other Unicode whitespace stay inside userinfo, so a secret holding them is redacted whole.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 14:06:19 +00:00
2d9e959c56 redact: drop needless mut
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 14:03:14 +00:00
9bd6936ea6 redact: char-safe, fail-closed run rule; cover Persona/Sleep structured output
Candidate run is everything to the next whitespace, userinfo up to its last @; protocol-relative
// and the narrow word:secret@host.tld/ shape are covered; scanning no longer slices inside a
multibyte character. persona::structured_output redacts each string through the same function.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 14:01:35 +00:00
480f94875b mind-self-run: redact URL userinfo from a result's text before journal and reply
One function, redact_url_userinfo, owned by result_text: the journal line and the admitted
PipelineReply both read the result through it, and redaction runs before the reply is chunked.
scheme://user:tok@h and http(s)/ftp(s)://tok@h become scheme://<redacted>@h; ssh://git@h stays.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 13:53:22 +00:00
ceb1dbdb86 Merge pull request 'Steward rulings: a peer steward's decision is a typed ruling in the follower's mind (stacked on #46)' (#48) from hands/eureka-body-steward-rulings into main 2026-11-11 12:56:57 +00:00
f8558a12a3 persona: a rule's words are four words of her own line outside any quotation form or quoted reply; tests pin later lines and title control
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 12:48:32 +00:00
692586d607 Merge origin/main into hands/eureka-body-steward-rulings
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 12:42:04 +00:00
bbb05b51a4 Merge pull request 'mind-body: chat_pending.v3 tail_gaps (defaulted); a named room reads as unknown, not quiet' (#49) from eureka-substrate/pending-tail-gaps into main 2026-11-11 12:34:35 +00:00
0f2652d7fa mind-body: chat_pending.v3 tail_gaps, a defaulted fourth field; a named room is unknown in the first line, never quiet
Bifrost b2e0bb2 sends [persona, deliveries, tail, tail_gaps]; a three-field decode failed LengthMismatch. The field defaults empty, so three-field answers still decode, and render_tail splits unknown (named) from quiet (unnamed, no line).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 12:27:42 +00:00
a26bce249d persona: a rule's words are a clause of her own line outside quotation, its campaign is checked before any effect, a co-stewarded repo is not a rule, and the repo fault names her repos; tests pin the survivors
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 12:26:47 +00:00
96a88d3cb6 Merge pull request 'persona: another Mind reads as role=mind from the swarm's addressing table' (#46) from hands/eureka-body-persona-mind-role into main 2026-11-11 12:17:47 +00:00
3389ead5a2 Merge remote-tracking branch 'origin/hands/eureka-body-persona-mind-role' into hands/eureka-body-steward-rulings 2026-11-11 12:17:43 +00:00
c243d731eb persona tests: pin the addressing table at every site that reads a role
Soul s1 findings trace-and-render-wiring-unpinned and tail-and-earlier-role-unpinned:
trace inputs, earlier and tail lines, tail and burst size measures, the tell's
people and open_direct refusal now fail when handed an empty table.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 12:09:40 +00:00
aa15dfb36e stream tests: the key reader's rules pinned in every shape Soul mutated
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 12:07:22 +00:00
bf3f610b66 mind-stream: the thought's model comes from the Projector; connector key reader and membrane pin tested; swarm stop line derived
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 11:58:12 +00:00
416fd7f719 persona: rule records a peer steward's decision as authority Steward; addressing rows carry stewarded repos; recall names the decider
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 11:56:53 +00:00
c2f251cb26 huginn-mind: a Steward ruling is selected by decider and repo, indexed by her words, and printed with who decided it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 11:56:53 +00:00
4e9d1dbd23 stream tests: take the recorded request id by value
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 11:40:35 +00:00
c9a8e73dbc pipeline: RulingAuthority::Steward names a peer steward's decision, her stewardship and her words
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 11:39:17 +00:00
8a324186cf mind-control: a brake hold stops mind-stream@ with the Mind's other units
The hold's unit list is one owner (held_templates); the operator line is printed from stop_args.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 11:37:34 +00:00
ee72561716 mind-stream: the Projector is her own connector caller; Ollama keeps only the raw continuer
KitProjector sends one tool-less kit turn per tick as caller <instance>; OllamaStream's /api/chat Projector and StreamModel are deleted, replaced by the Projector and Continuer ports.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 11:37:34 +00:00
8018322b2a persona tests: goldens read libby as role=mind
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 11:37:21 +00:00
69f9003b9b persona: another Mind reads as role=mind from the swarm's addressing table
Config::sender is deleted; sender_role over the session's addressing decides a
sender's role on message, earlier and tail lines, trace inputs, the names a
memory may be about, and who she may open a direct with.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 11:35:11 +00:00
995a5b88f9 Merge pull request 'huginn: --session falls back to HUGINN_SESSION, set by mind-self-run' (#45) from eureka-substrate/session-from-env into main 2026-11-11 09:29:56 +00:00
37be2cd305 mind-self-run: the child's HUGINN_SESSION is its run's label
Spawner::spawn takes the session to export; the Self run gives the bare run label (the spelling the run's opening receipt carries). The Persona gives none.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 09:21:26 +00:00
4674d5f283 huginn: --session falls back to HUGINN_SESSION for admit, import and persona put
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 09:03:50 +00:00
cc0709039f Merge pull request 'Persona first line carries the mouth's room tail (wake-reads-room-tail)' (#44) from eureka-substrate/persona-room-tail into main 2026-11-11 08:43:12 +00:00
0ee9c0228e Room tail: consumer-cap, hostile-field and key-dedupe tests; real three-field v3 over RUDP; a fully deduped tail is not called quiet; deploy-order note
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 08:34:51 +00:00
7eb0440e00 Room tail comes from the mouth's chat_pending.v3 tail field; the in-process accumulator is deleted
ChatPending gains a defaulted Option tail (old two-field mouths still decode), Chat::pending returns it, and a process's first line renders it: speakable rooms only, 20 per room and 60 in all, byte bound counting framing, quoted untrusted data, dedupe by event id against waiting deliveries, a marker for dropped lines and for rooms with none, and an unavailable note when the mouth sends no tail. A failed first poll fails the start as Chat again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 08:24:23 +00:00
9c1a5dba07 Persona process opens with the tail of her rooms: lines the mouth delivered through the ack, bounded, framed as untrusted data, unavailable noted
Matrix is the record and the mouth its only reader: the tail is assembled from the deliveries and contexts the mouth already sends (no new endpoint, no stored copy). Newest 60 lines / 24 KiB, oldest dropped with a marker; public sessions read the rooms she may speak in, a DM session its own room alone; a first poll that fails degrades to a tail-unavailable note instead of failing the wake.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 07:50:14 +00:00
95713735aa Merge pull request 'Persona wakes on address only: chat_pending.v3 basis/form, unaddressed acked in sequence' (#43) from eureka-substrate/address-only-wake into main 2026-11-11 07:27:20 +00:00
fa90daf8e6 Address-only wake tests: the stale-send revision, an unaddressed line behind a told one, the retried ack, and a project-only message told as a message
The Soul's surviving mutants n01, n08, n09 and the vacuous project test (n07, n11) now fail the suite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 07:27:19 +00:00
2f04e13ea5 Address-only wake: a DM is hers whatever the basis; the quiet ack reads her projects' names from her mind; no quiet ack while a stale send waits; a refused ack in a poll is retried
Soul findings F1, F2, F3 and F6 on PR 43.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 07:13:07 +00:00
50d8e60102 Persona wakes on address only: chat_pending_request.v2 / chat_pending.v3 basis and form; unaddressed deliveries are acked in sequence and tell no one
Consumer half of ruling eureka-body:ruling:addressed-delivery-only. A delivery is for a Persona when it is a message and Matrix says
direct/tag/reply, or its mentions or text name her (address::is_for); notices, edits, @room and a nil basis (unproven) wake no one on
their own. The heartbeat's off-beat wake keeps its sender gate. The Persona acks the leading run of what is not hers in one ack and a
live session is told only what is hers. A mouth that does not serve the v2 request is asked the v1 way. Golden bytes pinned from Bifrost
tests/fixtures/persona-chat-v5; the public-surface snapshot is regenerated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 06:43:46 +00:00
470c188d4c Merge pull request 'Vectors re-vendored from CultLib de8a062a (answerTooLarge replay)' (#42) from eureka-substrate/revendor-de8a062a into main 2026-11-11 02:18:21 +00:00
aae8fe2571 Vectors re-vendored from CultLib de8a062a; the answerTooLarge vector replays over a stale answer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 02:13:48 +00:00
d6068e7c04 Merge pull request 'Desk door tests: the exact reply-cap boundary of a Fork read; a stale answer is stored and replays while the fork is oversize' (#41) from eureka-substrate/read-cap-boundary into main 2026-11-11 01:52:09 +00:00
598f1db7f9 Desk door: socket tests pin the exact reply cap on a read, and a stale answer's stored refusal and replay while the fork is oversize
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 01:46:20 +00:00
797255eefe Merge pull request 'Desk reads: a read of an oversize fork is fork_too_large; vectors re-vendored from CultLib f6c9bf5f' (#40) from eureka-substrate/read-fork-too-large into main 2026-11-11 01:39:40 +00:00
711514602b Refusal replay is a property over the vectors' cases; the vector's error string is Huginn-independent
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 01:34:57 +00:00
9d0a30947a Vectors re-vendored from CultLib f6c9bf5f; replays of short pages and fork_too_large refusals; the refusal-text replay fails on a real disagreement
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 01:32:59 +00:00
4c1f247898 Desk door: a read of one fork past the reply cap is fork_too_large naming the fork (no order, no cursor)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 01:28:57 +00:00
5af0cbea65 Desk door: failing socket test, a read of an oversize fork is fork_too_large naming the fork
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 01:26:57 +00:00
d91985b521 Merge pull request 'Desk reads: fork_too_large (list only) replaces DeskForkTooLarge' (#39) from eureka-substrate/fork-too-large into main 2026-11-11 01:03:55 +00:00
cdc21dd396 Desk door: fork_too_large at order 0 says the list ends instead of naming c0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 01:03:42 +00:00
c6e1b41967 Desk door: socket tests for fork_too_large as the oldest (no c0) and newest fork
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 01:02:00 +00:00
c2aef3bc58 Desk door: DeskForkTooLarge becomes the not-stored refusal fork_too_large (list only), text names fork id and order; schemas regenerated
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 00:56:36 +00:00
5f3319f4ad Desk door: failing socket test for fork_too_large (list names fork and order, cursor resumes, read never returns it)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 00:51:57 +00:00
2662a34a41 Merge pull request 'Desk door reads: Forks and Fork on the peer-checked socket (whole record), unique order rank, the leftover Lows' (#38) from eureka-substrate/desk-door-reads-r2 into main 2026-11-11 00:46:59 +00:00
9e7dca2716 Desk reads: order is a unique 0-based rank by (admission, key); pages hold at most limit and stop at the byte budget; a lone oversize first entry is the named refusal with its order; vectors re-vendored from CultLib 9d6ee72e
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 00:36:48 +00:00
288d17cad3 Desk reads: order is a unique 0-based rank; failing tests first (batch of 64 pages, rank stability, gaps, lone oversize, vectors replay); DeskForkTooLarge names the order
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 00:31:57 +00:00
a056ea814d Merge pull request 'open-direct lows: the open hears the mouth's refusal; one copy of the daily policy text' (#37) from eureka-substrate/open-direct-lows into main 2026-11-11 00:15:09 +00:00
05a6726738 Tests kill three open_direct mutants: schema field description, non-UnknownKind refusal, payload-persona mismatch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 00:14:55 +00:00
071dc8e345 Desk door test: the page is short of the bound by the Mind's fill; a group past the budget is the Mind's refusal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 00:09:10 +00:00
aeeb36ad2a Desk answer: delete the replayed flag nobody read; tests assert that a replay commits nothing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 00:06:27 +00:00
3b775a23c8 Desk door tests: a peer that never reads fails the suite by assertion in five seconds, not by hanging it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 00:06:27 +00:00
31cc69922e Desk reads: a page fills to the response budget and never splits a same-order group
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 00:05:08 +00:00
e9757d7f45 Desk reads: page budget and same-order groups, failing tests first (DeskForkTooLarge, DESK_PAGE_BUDGET; the fill is not yet written)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 00:03:06 +00:00
707642bd54 Desk reads: Mind::desk_forks and desk_fork project card, record and entry at one snapshot
Newest first by admission ordinal, cursor strictly below, counts over the showable set, limit 0 is
counts and stamp. Whole record to the peer-checked socket; the trust boundary is pinned in the
desk_door module doc and the DeskRequest/DeskResponse docs. Published schemas regenerated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 23:57:27 +00:00
a37bd4f85f Desk reads: the wire types, the request fields and the failing tests (stub reads answer fork_unknown)
Types ForkCard/Record/Entry/List, ForkCursor and ForkLimit validated at decode, DeskRequest::Forks
gains status/cursor/limit flat, DeskResponse gains Forks and Fork, the daemon dispatches to
Mind::desk_forks/desk_fork (stubs). Vectors vendored at CultLib e57fbaee. The tests pin the rules
the next commit implements; DESK_READS_UNBUILT and its test are gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 23:50:27 +00:00
88a36386a5 open_direct hears the mouth's UnknownKind refusal and is told not sent; one creation-policy text for the schema and the DailyLimit notice; pin InvitePending wording
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 23:46:35 +00:00
e43a7343f8 Chat RUDP test: an open the mouth refuses as UnknownKind ends the wait and is heard (failing: the refusal is ignored); MouthRefused error type
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 23:44:30 +00:00
03a045de02 Merge pull request 'Desk door: the second peer-checked unix-socket door (Answer and catalog; reads interim)' (#36) from eureka-substrate/desk-answer-door into main 2026-11-10 23:38:38 +00:00
173f28b886 Desk door tests: the one-second lower edge allows jiffy granularity (950 ms still kills a 500 ms bound)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 23:38:33 +00:00
ba9b1013ca Desk door fix batch: the binary tests run one at a time and the accept-error probe fails every new descriptor
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 22:22:18 +00:00
4e43e43a26 Desk door fix batch: one deadline per connection (reads and writes spend what is left), the catalog reply bounded by the response limit, the dead index call and answer_ref deleted
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 22:17:04 +00:00
2b6e15de4c Desk door fix batch: failing tests first (one deadline per connection, the exact bounds, the catalog under the response limit; Soul's survivor probes committed)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 22:13:46 +00:00
4437afbc26 Desk door part B: the binary end-to-end (both doors, restart replay, leftover socket, other uid refused and logged, namespace root seen as the real uid)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 21:42:21 +00:00
035a3eaa62 Desk door part A: the oversize-header test fails fast instead of waiting when the bound is gone
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 21:40:52 +00:00
eca3f53c98 Desk door part A: mutation survivors pinned (header-only oversize refusal, four accepts per pass, unprobeable and unreadable socket paths)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 21:09:57 +00:00
e35181b296 Desk door part A: the unix-socket door, peer uid checked before a byte is read, joined to the one serve loop
desk_door.rs owns who may speak, the socket's life and the frame bounds; Daemon::handle_desk owns which Mind method answers (Answer via desk_answer, the replay flag gating index.committed; Forks and Fork refused Unavailable until desk-door-reads). startup returns Started; run takes the door; its five callers pass it or None. desk_wire::answer_ref is the one spelling of a stored answer's ref.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 20:56:51 +00:00
88ca0a0867 Desk door part A: failing tests first (peer check before a byte, bounds, answers over the socket, per-call deciders, interim reads refusal, startup gates)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 20:53:42 +00:00
d38db97823 Merge pull request 'Desk wire, part 2: the envelope (wire names mind/authority/subject/deciders, deciders 1..64 unique, generic cores, schemas)' (#35) from eureka-substrate/desk-wire-envelope into main 2026-11-10 20:47:56 +00:00
14a914a634 Desk wire envelope delta: decode refuses an empty decider list (minItems 1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 20:44:11 +00:00
0e590f3638 Desk wire envelope delta: failing tests first (empty deciders refused at decode, minItems 1, refusal round trip)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 20:43:31 +00:00
a19fc5b494 Desk wire envelope: DeskRequest with Libby's wire names, deciders per call (max 64), generic envelope cores, desk schemas
envelope.rs cores are generic over service id, schema and payload; the mind's five functions and the desk's five instantiate them. desk_answer's attribution parameter is authority. huginn.desk_request.v1 and huginn.desk_response.v1 are derived and pinned.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 20:31:21 +00:00
ab1b150d2f Desk wire envelope: failing tests first (deciders 64/65, wire names, no crossing, frame, schemas)
The desk request, response, envelope instantiations and constants do not exist yet; these tests name them and do not compile.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 20:28:16 +00:00
9c28a7a9ef Merge pull request 'Pin the InvitePending notice: a pending invitation is told not to be sent again' (#34) from eureka-body/open-direct-pin-wording into main 2026-11-10 20:26:32 +00:00
63d1825628 Pin the InvitePending notice: tell her not to send another invite
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 20:26:19 +00:00
8fef8bdd9e Merge pull request 'Persona: open_direct, her ask that the mouth open a DM with a person she was shown' (#32) from eureka/persona-open-direct into main 2026-11-10 20:22:48 +00:00
c5a98a2884 Merge pull request 'Desk wire lows: replay gate pinned for stored refusals and revisions; every bad fork id is fork_unknown' (#33) from eureka-substrate/desk-wire-s2-lows into main 2026-11-10 19:57:54 +00:00
7550514dcf Desk wire: any bad fork id is fork_unknown, including the key and campaign the Mind derives from it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 19:57:34 +00:00
c64ce59c36 Desk wire tests: a stored refusal and a stored revision replay under an empty decider list
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 19:56:54 +00:00
664be6e77a Merge pull request 'Desk wire, part 1: Libby's answer documents, the one n<seq> conversion, the projection and Mind::desk_answer' (#31) from eureka-substrate/desk-wire into main 2026-11-10 19:44:17 +00:00
732ed009f2 Persona: pin the schema a Direct session runs under; describe open_direct in the module doc
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 19:25:06 +00:00
bd53de4ec6 Persona: her reply may carry one open_direct, bounded and sent after the says
Public sessions only; with a person her session was shown (not a Mind, not herself); text 1..4000 bytes; brakes re-read before the open; a refusal or a mouth that does not answer is a notice on her next line, never a failed step. No consent state is kept: the mouth reads it from Matrix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:58:13 +00:00
4bb5cd6657 Open-direct: the chat wire types and trait method, and the failing Persona tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:51:10 +00:00
cc5937d37c Open-direct wire: Bifrost's v4 fixtures and the failing codec tests
Fixtures copied byte-for-byte from Bifrost main 73c6f50 (tests/fixtures/persona-chat-v4/), verified equal to the handoff copy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:47:29 +00:00
76 changed files with 13660 additions and 1287 deletions

2
Cargo.lock generated
View file

@ -935,6 +935,7 @@ dependencies = [
"cultnet-rs 0.1.0",
"huginn-mind",
"rmp-serde",
"rustix",
"serde",
"serde_json",
"sha2",
@ -1177,6 +1178,7 @@ source = "git+https://eureka.gamecult.org/forge/GameCult/ModelConnector.git?rev=
dependencies = [
"aes-gcm",
"base64",
"cultcache-rs 0.3.1",
"cultnet-rs 0.2.0",
"rand_core 0.6.4",
"rmp-serde",

View file

@ -51,6 +51,7 @@ finding.plausible-blocker-preexisting-kinds eureka-state:finding:cut-3a.s2.F4 ep
finding.severity-medium eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a64d656469756da5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739183a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564
finding.severity-low eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a34c6f77a5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739183a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564
ruling.authority-mind eureka-state:ruling:R8 epiphany.pipeline.ruling.v2 EpiphanyPipelineRulingDocument 918aa863616d706169676eac657572656b612d7374617465a56c6162656ca25238a57469746c65b9416e20696e7374616e6365206f776e7320697473206d696e64a7616e7377657273c0a663686f696365c0a672756c696e67ba416e20696e7374616e6365206f776e7320697473206d696e642eae6f70657261746f725f71756f7465c0a872756c65645f6f6eaa323032362d30392d3135aa707265636564656e74739185a47265706fb147616d6543756c742f4165746865726961a6636f6d6d6974d92861616161616161616161616161616161616161616161616161616161616161616161616161616161a46b696e64a652756c696e67a26964b363756c7463616368653a72756c696e673a5231ae7061796c6f61645f736861323536d94062626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262a9617574686f72697479a44d696e64
ruling.authority-steward eureka-state:ruling:R8 epiphany.pipeline.ruling.v2 EpiphanyPipelineRulingDocument 918aa863616d706169676eac657572656b612d7374617465a56c6162656ca25238a57469746c65b9416e20696e7374616e6365206f776e7320697473206d696e64a7616e7377657273c0a663686f696365c0a672756c696e67ba416e20696e7374616e6365206f776e7320697473206d696e642eae6f70657261746f725f71756f7465c0a872756c65645f6f6eaa323032362d30392d3135aa707265636564656e74739185a47265706fb147616d6543756c742f4165746865726961a6636f6d6d6974d92861616161616161616161616161616161616161616161616161616161616161616161616161616161a46b696e64a652756c696e67a26964b363756c7463616368653a72756c696e673a5231ae7061796c6f61645f736861323536d94062626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262a9617574686f7269747981a75374657761726484a773746577617264ae406865696d64616c6c3a63686174a47265706fb047616d6543756c742f426966726f7374a5776f726473b4726566757365206c656164696e67207a65726f73a56576656e74a424657631
cut_spec.reads-and-symbol eureka-state:cut_spec:cut-3a.r1 epiphany.pipeline.cut_spec.v2 EpiphanyPipelineCutSpecDocument 91de0013a863616d706169676eac657572656b612d7374617465a3637574a23361a87265766973696f6e01a57469746c65b2506970656c696e6520646f63756d656e7473a47265706fb147616d6543756c742f4570697068616e79a66272616e6368bb636f6465782f657572656b612d706970656c696e652d7374617465a462617365a83566393832323864aa646570656e64735f6f6e91a132a5666972737491ae526561642074686520737065632ea764656c657465739183a470617468a66f6c642e7273a56c696e657303a46e6f7465a464656164ab6b656570735f6d6f76657391ac636f6d6d6974206f776e6572a46164647391b5706970656c696e655f646f63756d656e74732e7273ac66696c655f6368616e6765739182a86c6f636174696f6e84a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509a673796d626f6cab76616c75655f7479706573a66368616e6765a3616464a572656164739282a86c6f636174696f6e84a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e650aa8656e645f6c696e6514a673796d626f6caf506970656c696e6543757453706563a3776879b274686520646f63756d656e7420736861706582a86c6f636174696f6e83a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e651ea8656e645f6c696e65c0a3776879a86f6e65206c696e65ad617574686f726974795f6d617087a56f776e6572a4636f7265a6696e7075747391af747970656420646f63756d656e7473a76f75747075747391a9656e76656c6f706573ad646572697665645f737461746591ac64657269766564206b657973b1666f7262696464656e5f7772697465727391a34d4350ac7368617265645f706174687391a961646d697373696f6ead64656c6574696f6e5f6c696e65a36e2f61ac766572696669636174696f6e84a66275696c647391ab636172676f20636865636ba574657374739182a46e616d65a46b657973a470696e73af6f6e652064657269766564206b6579a86e656761746976659182a77061747465726ea75665633c75383ea573636f7065a9646f63756d656e7473a86f70657261746f7291a46e6f6e65a8657374696d61746588ab6c696e65735f6164646564cd0384ad6c696e65735f72656d6f76656400b2646570656e64656e636965735f616464656491a8736368656d617273b4646570656e64656e636965735f72656d6f76656490ad666f726d6174735f616464656491b66570697068616e792e706970656c696e652e2a2e7631af666f726d6174735f72656d6f76656490ad746172676574735f616464656490af746172676574735f72656d6f76656490a772756c696e677391b6657572656b612d73746174653a72756c696e673a5238a97175657374696f6e7391b8657572656b612d73746174653a7175657374696f6e3a5131
finding.location-symbol eureka-state:finding:cut-3a.s2.F4 epiphany.pipeline.finding.v2 EpiphanyPipelineFindingDocument 918da863616d706169676eac657572656b612d7374617465a776657264696374be657572656b612d73746174653a766572646963743a6375742d33612e7332a56c6162656ca24634a572616e676582a462617365a83566393832323864a468656164a83566393832323864aa636f6e666964656e6365a9436f6e6669726d6564a87365766572697479a448696768a5636c61696dd9214120646f74746564206c6162656c20636f6d706f7365732074776f206b6579732eaa696e76617269616e747391ab6d696e642d61646d697473a96c6f636174696f6e739184a470617468d9216372617465732f657572656b612d706970656c696e652f7372632f6c69622e7273a46c696e6501a8656e645f6c696e6509a673796d626f6cab2631313533303930343938b06661696c7572655f7363656e6172696fbc54776f20646f63756d656e747320636c61696d206f6e65206b65792ea865766964656e63659183a46b696e64a454657374a76c6f6361746f72aa636172676f2074657374a6726573756c74a26f6baa707265636564656e747390a66f726967696eaa496e74726f6475636564
target.repos eureka-state:target:r2 epiphany.pipeline.target.v2 EpiphanyPipelineTargetDocument 9187a863616d706169676eac657572656b612d7374617465a87265766973696f6e02aa696e76617269616e74739182a56c6162656cab6d696e642d61646d697473a973746174656d656e74b64f6e6c792061646d697373696f6e207772697465732eac6e6f745f696e5f73636f706591ac4576652062726f7773696e67b963616e6f6e6963616c5f696d706c656d656e746174696f6e7391a743756c744c6962a57265706f7392b147616d6543756c742f4570697068616e79af47616d6543756c742f487567696e6ea3646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864

View file

@ -759,7 +759,6 @@ unit_enums! {
ClaimOutcome { Holds, Falsified, Unproven }
FindingConfidence { Confirmed, Plausible }
FindingSeverity { Blocker, High, Medium, Low }
RulingAuthority { Operator, Standing, Defaulted, Mind }
FindingOrigin { Introduced, PreExisting }
RunOperator { Operator, Mind }
RunTurn { PersonaTurn, SelfRun, Sleep }
@ -1127,6 +1126,55 @@ impl Bounded for ResearchSource {
}
}
/// Who decided a ruling, and under what. `Mind` is this mind's own decision, admitted by her Self. `Steward` is
/// a named peer's decision inside the peer's stewardship, as this mind heard it: `steward` is the deciding Mind's
/// Matrix id, `repo` the stewardship she decided under, `words` her own words copied from the line this mind heard,
/// `event` that line's Matrix event id when it was a delivery. The four unit variants stay bare strings on the wire.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema)]
pub enum RulingAuthority {
Operator,
Standing,
Defaulted,
Mind,
Steward { steward: Short, repo: OrgRepo, words: Para, event: Option<Short> },
}
impl RulingAuthority {
/// The variants' spellings, in declaration order: the names the wire and listings use.
pub const NAMES: &'static [&'static str] = &["Operator", "Standing", "Defaulted", "Mind", "Steward"];
/// The variant's spelling, for rows, headers and listings.
pub fn name(&self) -> &'static str {
match self {
Self::Operator => "Operator",
Self::Standing => "Standing",
Self::Defaulted => "Defaulted",
Self::Mind => "Mind",
Self::Steward { .. } => "Steward",
}
}
}
impl Bounded for RulingAuthority {
fn validate(&self, field: &str) -> Result<(), PipelineRefusal> {
match self {
Self::Steward { steward, repo, words, event } => {
steward.validate(&format!("{field}.steward"))?;
if steward.0.trim().is_empty() {
return Err(format_error(&format!("{field}.steward"), "a steward naming no Mind"));
}
repo.validate(&format!("{field}.repo"))?;
words.validate(&format!("{field}.words"))?;
if words.0.trim().is_empty() {
return Err(format_error(&format!("{field}.words"), "a steward ruling with no words"));
}
event.validate(&format!("{field}.event"))
}
_ => Ok(()),
}
}
}
/// How a subject was resolved. Every referent is a parsed `PipelineRef`, so a
/// resolution names its records by ids of the kinds they declare, validated
/// where every other referent is; a `Fixed` commit is a `Sha` whose referent
@ -1923,6 +1971,50 @@ mod tests {
ruling
}
fn steward_ruling() -> PipelineRuling {
let mut ruling = mind_ruling();
ruling.authority = RulingAuthority::Steward {
steward: "@heimdall:chat".into(),
repo: OrgRepo("GameCult/Bifrost".into()),
words: "refuse leading zeros".into(),
event: Some("$ev1".into()),
};
ruling
}
#[test]
fn a_steward_ruling_round_trips_and_leaves_the_unit_authorities_bare_strings() -> Result<()> {
let cache = schema_cache()?;
let document = PipelineDocument::Ruling(steward_ruling());
document.validate()?;
assert_eq!(PipelineDocument::decode(&document.prepare(&cache)?)?, document);
let json = serde_json::to_value(&document)?;
assert_eq!(json["value"]["authority"]["Steward"]["steward"], "@heimdall:chat");
let mut unit = ruling_sample();
unit.authority = RulingAuthority::Defaulted;
assert_eq!(serde_json::to_value(PipelineDocument::Ruling(unit))?["value"]["authority"], "Defaulted");
Ok(())
}
#[test]
fn a_steward_ruling_refuses_blank_words_blank_steward_and_a_bad_repo() {
let refused = |edit: fn(&mut RulingAuthority)| {
let mut ruling = steward_ruling();
edit(&mut ruling.authority);
PipelineDocument::Ruling(ruling).validate().expect_err("refused").to_string()
};
let fields = [
refused(|a| if let RulingAuthority::Steward { words, .. } = a { words.0 = " ".into() }),
refused(|a| if let RulingAuthority::Steward { steward, .. } = a { steward.0 = String::new() }),
refused(|a| if let RulingAuthority::Steward { repo, .. } = a { repo.0 = "nope".into() }),
refused(|a| if let RulingAuthority::Steward { words, .. } = a { words.0 = "w".repeat(4001) }),
refused(|a| if let RulingAuthority::Steward { event, .. } = a { *event = Some("e".repeat(201).as_str().into()) }),
];
for (fault, field) in fields.iter().zip(["authority.words", "authority.steward", "authority.repo", "authority.words", "authority.event"]) {
assert!(fault.contains(field), "{fault}");
}
}
/// A withdrawal is a pulse that says only `tempo_vote_withdrawn`: it must survive
/// encoding to an envelope, JSON and named MessagePack as a withdrawal, and an
/// unwithdrawn pulse must not write the key.
@ -1967,7 +2059,7 @@ mod tests {
#[test]
fn unit_enum_names_are_the_variants_in_declaration_order() {
assert_eq!(RulingAuthority::NAMES, ["Operator", "Standing", "Defaulted", "Mind"]);
assert_eq!(RulingAuthority::NAMES, ["Operator", "Standing", "Defaulted", "Mind", "Steward"]);
assert_eq!(FindingOrigin::NAMES, ["Introduced", "PreExisting"]);
}
@ -2044,6 +2136,7 @@ mod tests {
out.push((format!("finding.severity-{name}"), D::Finding(f)));
}
out.push(("ruling.authority-mind".into(), D::Ruling(mind_ruling())));
out.push(("ruling.authority-steward".into(), D::Ruling(steward_ruling())));
let PipelineDocument::CutSpec(mut spec) = samples().remove(4).0 else { unreachable!() };
spec.file_changes[0].location.symbol = Some("value_types".into());
spec.reads = vec![

View file

@ -15,6 +15,10 @@ use crate::trouble::Trouble;
pub const TOOL: &str = "huginn";
/// How long one call may take, connection to last byte.
const TIMEOUT: Duration = Duration::from_secs(15);
/// The environment variable a Self run sets to its run label: the session an admitting verb attributes to when `--session` is not given.
pub const SESSION_VARIABLE: &str = "HUGINN_SESSION";
/// `Short`'s bound in bytes (the leaf keeps it private); the daemon enforces it again.
const SESSION_MAX: usize = 200;
/// What `ask_until` leaves of its deadline for the caller to print and exit.
const RESERVE: Duration = Duration::from_millis(100);
@ -39,6 +43,22 @@ impl Settings {
self.client.as_ref().map_err(|detail| Trouble::Misconfigured(detail.clone()))
}
/// The session an admission is attributed to: the flag as given; else `HUGINN_SESSION` when it
/// is set and not empty; else a refusal. Never guessed and never any other variable.
pub fn session(&self, flag: Option<String>) -> Result<String, Trouble> {
if let Some(flag) = flag {
return Ok(flag);
}
match variable(SESSION_VARIABLE) {
Err(detail) => Err(Trouble::invalid(detail)),
Ok(Some(value)) if value.len() > SESSION_MAX => {
Err(Trouble::invalid(format!("{SESSION_VARIABLE} is longer than {SESSION_MAX} bytes")))
}
Ok(Some(value)) if !value.is_empty() => Ok(value),
Ok(_) => Err(Trouble::invalid(format!("--session is required (or {SESSION_VARIABLE} set to the session's label)"))),
}
}
/// A client for another mind, read from: `instance` at `endpoint` (`rudp://<ip>:<port>`), both
/// given as arguments. It refuses this process's own instance before any call, so the one
/// mind a verb writes is never also its source. Every request through it declares `instance`,

View file

@ -4,9 +4,9 @@
//! strict decode of agent-written JSON into the leaf's types (`door`), the
//! recipe selections (`recipes`), the text rendering (`render`), the exit code
//! and the read-back after an admission. It owns no state, writes no file and
//! reads no configuration but two environment variables: `EUREKA_INSTANCE`
//! reads no configuration but these environment variables: `EUREKA_INSTANCE`
//! names the mind and `HUGINN_ENDPOINT` (`rudp://host:port`) the daemon that
//! holds it. Each call is a new process; there is no retry inside it, the
//! holds it. The admitting verbs take `--session`, else `HUGINN_SESSION`. Each call is a new process; there is no retry inside it, the
//! caller's rule retries `Unavailable` once.
//!
//! Answers go to stdout and the exit code says what the answer was: 0 answered,
@ -125,9 +125,9 @@ enum Command {
/// The admitting agent's name.
#[arg(long)]
agent: String,
/// The admitting session.
/// The admitting session; else the environment's HUGINN_SESSION, which a Self run sets to its run.
#[arg(long)]
session: String,
session: Option<String>,
/// The token of the hold this exact batch got: admit it.
#[arg(long)]
seen: Option<String>,
@ -152,9 +152,9 @@ enum Command {
/// The importing agent's name.
#[arg(long)]
agent: String,
/// The importing session.
/// The importing session; else the environment's HUGINN_SESSION.
#[arg(long)]
session: String,
session: Option<String>,
},
/// Recall into a dispatched brief.
Recall {
@ -198,9 +198,9 @@ enum PersonaCommand {
/// The writing agent's name.
#[arg(long)]
agent: String,
/// The writing session.
/// The writing session; else the environment's HUGINN_SESSION.
#[arg(long)]
session: String,
session: Option<String>,
/// The stored updatedAt this put replaces; omit for the first put.
#[arg(long)]
expected_updated_at: Option<String>,
@ -290,6 +290,7 @@ fn run(settings: &Settings, command: Command) -> Result<u8, Trouble> {
}
Command::Admit { faculty, agent, session, seen, file } => admit(settings, faculty, agent, session, seen, &file),
Command::Import { campaign, from, from_endpoint, faculty, agent, session } => {
let session = settings.session(session)?;
import::run(settings, import::Flags { campaign, from, from_endpoint, faculty, agent, session })
}
Command::Persona { command: PersonaCommand::Get } => persona_get(settings),
@ -489,10 +490,11 @@ fn admit(
settings: &Settings,
faculty: Faculty,
agent: String,
session: String,
session: Option<String>,
seen: Option<String>,
file: &str,
) -> Result<u8, Trouble> {
let session = settings.session(session)?;
let documents = door::documents(&door::file_or_stdin(file)?)?;
if documents.is_empty() || documents.len() > BATCH_MAX {
return Err(Trouble::invalid(format!("a batch is 1 to {BATCH_MAX} documents, this one has {}", documents.len())));
@ -649,10 +651,11 @@ fn persona_put(
settings: &Settings,
faculty: Faculty,
agent: String,
session: String,
session: Option<String>,
expected_updated_at: Option<String>,
file: &str,
) -> Result<u8, Trouble> {
let session = settings.session(session)?;
let text = door::file_or_stdin(file)?;
let state: Value = serde_json::from_str(&text)
.map_err(|error| Trouble::invalid(format!("the persona document is not JSON: {error}")))?;

View file

@ -100,7 +100,17 @@ pub fn header(item: &PipelineDocumentSummary) -> String {
let _ = write!(line, " {name}");
}
for (key, value) in facts {
if key != "title"
if key == "authority"
&& let Some(Value::Object(steward)) = value.get("Steward")
{
// Her words stay out of the header: it names who decided and under what.
let _ = write!(line, " authority=Steward");
for field in ["steward", "repo"] {
if let Some(shown) = steward.get(field).and_then(scalar) {
let _ = write!(line, " {field}={shown}");
}
}
} else if key != "title"
&& key != "label"
&& let Some(shown) = scalar(value)
{
@ -256,6 +266,32 @@ two
assert_eq!(serde_json::from_str::<SourceHits>(&json).unwrap(), found, "--json round-trips");
}
#[test]
fn a_steward_ruling_header_names_the_decider_and_her_stewardship_but_not_her_words() {
use huginn_mind::eureka_pipeline::{OrgRepo, PipelineRuling, RulingAuthority};
let mut view = viewed(None, PipelineStatus::InForce);
view.document = PipelineDocument::Ruling(PipelineRuling {
campaign: Slug("c".into()),
label: "R1".into(),
title: "Zeros".into(),
answers: None,
choice: None,
ruling: "Refuse them.".into(),
operator_quote: None,
ruled_on: Date("2026-11-11".into()),
precedents: vec![],
authority: RulingAuthority::Steward {
steward: "@heimdall:chat".into(),
repo: OrgRepo("GameCult/Bifrost".into()),
words: "refuse leading zeros".into(),
event: None,
},
});
let line = header(&PipelineDocumentSummary::of(&view, &|_| None));
assert!(line.contains("authority=Steward steward=@heimdall:chat repo=GameCult/Bifrost"), "{line}");
assert!(!line.contains("zeros"), "{line}");
}
#[test]
fn a_fact_is_one_token_or_nothing() {
assert_eq!(scalar(&json!("")), None);

View file

@ -49,6 +49,7 @@ fn run(env: &[(&str, &str)], args: &[&str], stdin: Option<&str>) -> Run {
command
.env_remove("EUREKA_INSTANCE")
.env_remove("HUGINN_ENDPOINT")
.env_remove("HUGINN_SESSION")
.envs(env.iter().copied())
.args(args)
.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
@ -2782,3 +2783,81 @@ fn import_wrong_endpoint_is_foreign_instance() {
assert_eq!(own.code, 2, "{}{}", own.out, own.err);
assert!(own.err.starts_with("InvalidInput: --from heimdall is this process's own instance"), "{}", own.err);
}
/// Configured for the test instance against a daemon at `addr`, with `HUGINN_SESSION` as given.
fn with_session(addr: SocketAddr, session: &str, args: &[&str], stdin: Option<&str>) -> Run {
let endpoint = format!("rudp://{addr}");
run(&[("EUREKA_INSTANCE", INSTANCE), ("HUGINN_ENDPOINT", &endpoint), ("HUGINN_SESSION", session)], args, stdin)
}
fn admit_without_session(file: &str) -> Vec<&str> {
vec!["admit", "--faculty", "Hands", "--agent", "claude", file]
}
#[test]
fn the_session_is_the_flag_else_huginn_session_else_a_refusal() {
let dir = tempfile::tempdir().unwrap();
let file = write(&dir, "campaign.json", &json!([campaign_json()]));
let server = live();
// Neither: refused before any send, nothing admitted.
let neither = at(server.addr, &admit_without_session(&file), None);
assert_eq!(neither.code, 2, "{}", neither.err);
assert!(neither.err.starts_with("InvalidInput: --session is required"), "{}", neither.err);
assert_eq!(neither.out, "");
let count = at(server.addr, &["whoami"], None);
assert!(count.out.contains("receipts 2 "), "{}", count.out);
// An empty variable is absent.
let empty = with_session(server.addr, "", &admit_without_session(&file), None);
assert_eq!(empty.code, 2, "{}", empty.err);
assert!(empty.err.starts_with("InvalidInput: --session is required"), "{}", empty.err);
assert!(at(server.addr, &["whoami"], None).out.contains("receipts 2 "));
// The variable alone: the receipt carries it.
let env_only = with_session(server.addr, "mind-20261111T072944197Z", &admit_without_session(&file), None);
assert_eq!(env_only.code, 0, "{}", env_only.err);
let viewed = at(server.addr, &["view", &format!("{CAMPAIGN}:campaign:self")], None);
assert!(viewed.out.contains(" Hands/claude session mind-20261111T072944197Z tool huginn "), "{}", viewed.out);
assert!(at(server.addr, &["whoami"], None).out.contains("receipts 3 "));
// The persona door takes it the same way.
let persona_file = write(&dir, "persona.json", &persona(INSTANCE, "2026-10-10T10:00:00Z"));
let put = with_session(server.addr, "mind-run-p", &["persona", "put", "--faculty", "SelfFaculty", "--agent", "claude", &persona_file], None);
assert_eq!(put.code, 0, "{}", put.err);
let receipt = put.out.strip_prefix("Committed ").and_then(|rest| rest.strip_suffix('\n')).expect(&put.out);
let got = at(server.addr, &["persona", "get"], None);
assert!(got.out.contains(&format!("receipt {receipt}")), "{}", got.out);
}
#[test]
fn the_flag_wins_over_huginn_session() {
let (server, seen) = replying("admit", HuginnMindResponse::Admit(PipelineAdmissionOutcome::Committed { receipt_id: "mind-commit-x".into(), committed_at: "2026-11-11T00:00:00Z".into(), writes: vec![] }));
let dir = tempfile::tempdir().unwrap();
let file = write(&dir, "campaign.json", &json!([campaign_json()]));
let _ = with_session(server.addr, "from-env", &admit_args(&file), None);
let seen = seen.lock().unwrap();
let sessions: Vec<&str> = seen
.iter()
.filter_map(|request| if let HuginnMindRequest::Admit(batch) = request { Some(batch.provenance.session.0.as_str()) } else { None })
.collect();
assert_eq!(sessions, ["session-7"]);
}
#[test]
fn a_malformed_huginn_session_is_refused_naming_the_variable_without_echoing_it() {
let dir = tempfile::tempdir().unwrap();
let file = write(&dir, "campaign.json", &json!([campaign_json()]));
let (server, count) = counting(HuginnMindResponse::View(None), "view");
let long = "L".repeat(201);
let ran = with_session(server.addr, &long, &admit_without_session(&file), None);
assert_eq!(ran.code, 2, "{}", ran.err);
assert!(ran.err.starts_with("InvalidInput: HUGINN_SESSION is longer than 200 bytes"), "{}", ran.err);
assert!(!ran.err.contains("LLLL"), "{}", ran.err);
assert_eq!(*count.lock().unwrap(), 0, "nothing was sent");
// The bound itself is allowed.
let live = live();
let edge = with_session(live.addr, &"E".repeat(200), &admit_without_session(&file), None);
assert_eq!(edge.code, 0, "{}", edge.err);
}

View file

@ -164,7 +164,7 @@ pub fn serve<I: IndexSink<OwnedRedbMessagePackBackingStore> + Send + 'static>(
let loop_stopping = Arc::clone(&stopping);
let registry = schema_registry().unwrap();
let thread = std::thread::spawn(move || {
run(&mut daemon, &mut hub, &registry, &loop_stopping, &ServeOptions::default()).unwrap();
run(&mut daemon, &mut hub, &registry, None, &loop_stopping, &ServeOptions::default()).unwrap();
});
Server { addr, stopping, thread: Some(thread), ended: Arc::default(), _root: Some(root) }
}

View file

@ -30,6 +30,8 @@ chrono = "0.4.44"
cultnet-rs.workspace = true
huginn-mind = { path = "../huginn-mind" }
rmp-serde = "1"
# The desk door asks the kernel who is on the other end of a unix socket; mind-body's line.
rustix = { version = "1", default-features = false, features = ["std", "net"] }
serde = { version = "1", features = ["derive"] }
# The vector store's and the embedder's HTTP APIs are the xenos boundary, and
# JSON exists in `index/ollama.rs` and `index/qdrant.rs` only. `ureq` 3 with

View file

@ -5,6 +5,7 @@
use chrono::{DateTime, Utc};
use cultnet_rs::Selection;
use crate::sources::forge::HttpForge;
use huginn_mind::desk_wire::{DeskRequest, DeskResponse};
use huginn_mind::eureka_pipeline::{PipelineRef, Slug};
use huginn_mind::wire::{ACTIVITY_REPOS_MAX, HuginnMindRequest, HuginnMindResponse, IndexStatus};
use huginn_mind::{Members, Mind, MindRefusal, MindStore, PipelineAdmissionOutcome, SemanticQuery, SourceHits, SourceQuery};
@ -231,6 +232,24 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
Handled::Answered(answer)
}
/// One desk request, answered by the Mind method it names. The Mind is the instance's own
/// (`mind` is checked against it); `deciders` is this call's list, handed to the Mind and
/// dropped. A desk answer is not indexed (`index_text` gives it no text), so nothing here tells the index.
/// The reads answer with the whole record and nothing here gates them (ruling
/// `desk-read-visibility-whole-record`): they are handed `deciders` and not `subject`.
pub fn handle_desk(&mut self, request: DeskRequest, now: DateTime<Utc>) -> DeskResponse {
if let Err(refusal) = self.mind.require_instance(request.mind()) {
return DeskResponse::Refused(refusal);
}
match request {
DeskRequest::Answer { subject, authority, deciders, request, .. } => {
self.mind.desk_answer(&deciders, subject, authority, request, now)
}
DeskRequest::Forks { deciders, status, cursor, limit, .. } => self.mind.desk_forks(&deciders, status, cursor, limit),
DeskRequest::Fork { deciders, fork_id, .. } => self.mind.desk_fork(&deciders, &fork_id),
}
}
/// A semantic query starts only if the mind accepts it, and the mind
/// decides its members before the index is asked anything. A selection
/// with no members is answered at once: nothing is embedded and the index
@ -665,11 +684,11 @@ pub(crate) mod tests {
Ok(Daemon::new(Mind::open(state_root, instance)?, NoIndex))
}
type Seen = Vec<(Vec<String>, Vec<bool>)>;
pub(crate) type Seen = Vec<(Vec<String>, Vec<bool>)>;
#[derive(Clone, Default)]
struct RecordingIndex {
seen: Arc<Mutex<Seen>>,
pub(crate) struct RecordingIndex {
pub(crate) seen: Arc<Mutex<Seen>>,
}
impl<S: MindStore> IndexSink<S> for RecordingIndex {

View file

@ -0,0 +1,344 @@
//! The desk door: the second door onto the one mind, a unix socket one peer may speak on.
//!
//! This module owns three things. Who may speak: the kernel's report of the connecting
//! process's uid must equal the configured one, and it is asked before a byte is read, so a
//! refused peer has nothing read and nothing written. The socket's life: bind, replace a stale
//! one, refuse a live one or a file that is not a socket, mode 0660, unlink on drop. And the
//! bounds: one request per connection, one reply, a header over `DESK_MAX_REQUEST_BYTES` refused
//! before anything is allocated, one `DESK_CONNECTION_DEADLINE` for the whole connection (every
//! read and write spends what is left of it, so a peer that trickles bytes is cut off at the
//! deadline like one that is silent), a reply past `DESK_MAX_RESPONSE_BYTES` refused by name (a read of one
//! fork past it is that fork's `fork_too_large`).
//!
//! The trust boundary of the reads (ruling `desk-read-visibility-whole-record`). The peer-uid check
//! IS the whole authentication: Huginn must be reachable only by Bifrost's peer-checked socket, so
//! the configured uid is Bifrost's and no other process's. `Forks` and `Fork` return the WHOLE record
//! (cards, outcomes, every decider's note) to whoever passes that check; nothing in Huginn strips a
//! note or gates a read on `subject` or `deciders` (`deciders` only fills the card and the
//! showability gate). Bifrost applies visibility at its own door (a stripped read serves `note: null`,
//! CultLib `fork-card.md`). A second reader of this socket is therefore a note leak, and a deployment
//! that gives the socket to any uid but Bifrost's has no reader check left. The reads carry no
//! `authority` (a payload that names one is refused whole), and the door has no other way to ask who
//! is reading.
//!
//! It owns no rule. Which Mind method answers is `Daemon::handle_desk`'s; when the door is
//! served is `serve::run`'s, once per pass in the thread that owns the Mind. A peer that is
//! slow costs the loop at most `DESK_ACCEPTS_PER_PASS` connections of `DESK_CONNECTION_DEADLINE`
//! each, four seconds, and only the configured peer can reach that.
//!
//! The door keeps no request id: a retry after a lost reply is a new connection with the same
//! request, and the stored answer is what replays it, across a restart as well.
use std::fmt;
use std::fs;
use std::io::{self, Read, Write};
use std::os::fd::AsFd;
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail, ensure};
use chrono::{DateTime, Utc};
use cultnet_rs::{
CultNetMessage, CultNetSchemaKind, CultNetSchemaRegistration, CultNetSchemaRegistry, CultNetTransportProfile, CultNetWireContract,
TcpFramedTransportConnection, TcpFramedTransportProfileOptions, create_tcp_framed_transport_profile, decode_cultnet_message_from_slice,
encode_cultnet_message_to_vec,
};
use huginn_mind::desk_wire::{
DESK_MAX_REQUEST_BYTES, DESK_MAX_RESPONSE_BYTES, DESK_REQUEST_SCHEMA, DESK_REQUEST_SCHEMA_JSON, DESK_RESPONSE_SCHEMA,
DESK_RESPONSE_SCHEMA_JSON, DESK_SERVICE_ID, DeskResponse, fork_too_large_read,
};
use huginn_mind::envelope::{OperationFailure, decode_desk_request, encode_desk_failure, encode_desk_response};
use huginn_mind::{MindRefusal, MindStore};
use rustix::net::sockopt::{Timeout, set_socket_timeout};
use crate::daemon::{Daemon, IndexSink};
use crate::serve::{MAX_ECHOED_FIELD_BYTES, overlong_echo};
/// How long one connection of the configured peer may hold the loop, reading and writing together.
pub const DESK_CONNECTION_DEADLINE: Duration = Duration::from_secs(1);
/// Connections one loop pass serves, so a peer that connects in a stream cannot hold the RUDP door off.
pub const DESK_ACCEPTS_PER_PASS: u32 = 4;
/// The listening socket, and the one uid that may speak on it.
pub struct DeskDoor {
listener: UnixListener,
path: PathBuf,
peer_uid: u32,
registry: CultNetSchemaRegistry,
}
/// What became of one connection. `Display` is the log line for the ones that are not answered.
#[derive(Debug, PartialEq, Eq)]
pub enum Served {
Answered,
/// The peer's uid is not the configured one: nothing was read or written.
Refused { uid: u32, expected: u32 },
/// The connection ended without a reply, and why.
Dropped(String),
}
impl fmt::Display for Served {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Answered => formatter.write_str("desk door answered a peer"),
Self::Refused { uid, expected } => write!(formatter, "desk door refused a peer with uid {uid} (expects {expected})"),
Self::Dropped(why) => write!(formatter, "desk door dropped a connection: {why}"),
}
}
}
/// The two desk contracts, as the door's catalog advertises them and nothing else.
pub fn desk_registry() -> Result<CultNetSchemaRegistry> {
let mut registry = CultNetSchemaRegistry::new();
for (schema_id, title, schema_json) in [
(DESK_REQUEST_SCHEMA, "Huginn desk request", DESK_REQUEST_SCHEMA_JSON),
(DESK_RESPONSE_SCHEMA, "Huginn desk response", DESK_RESPONSE_SCHEMA_JSON),
] {
registry.register(CultNetSchemaRegistration {
schema_id: schema_id.to_string(),
kind: CultNetSchemaKind::WireMessage,
wire_contracts: vec![CultNetWireContract::CultNetSchemaV0],
schema_version: Some(schema_id.to_string()),
document_type: None,
title: Some(title.to_string()),
schema_json: Some(schema_json.to_string()),
})?;
}
Ok(registry)
}
/// Binds the desk socket. A path that exists and is not a socket is refused; a socket that
/// accepts a connection is a live server and is refused; a socket that refuses the connection is
/// stale and is replaced (`Mind::open`'s exclusive lock has already shown no live daemon of this
/// instance). The socket is then mode 0660 and non-blocking.
pub fn bind_desk(path: &Path, peer_uid: u32) -> Result<DeskDoor> {
ensure!(path.is_absolute(), "the desk socket path must be absolute, got {}", path.display());
ensure!(peer_uid != 0, "the desk peer uid must not be 0");
match fs::symlink_metadata(path) {
Ok(found) => {
ensure!(found.file_type().is_socket(), "{} exists and is not a socket", path.display());
match UnixStream::connect(path) {
Ok(_) => bail!("{} is a live socket; another server holds it", path.display()),
Err(error) if error.kind() == io::ErrorKind::ConnectionRefused => {
fs::remove_file(path).with_context(|| format!("removing the stale socket {}", path.display()))?;
}
Err(error) => return Err(error).with_context(|| format!("probing the socket {}", path.display())),
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
}
let listener = UnixListener::bind(path).with_context(|| format!("binding {}", path.display()))?;
// The door owns the path from here, so a failure below still unlinks it on drop.
let door = DeskDoor { listener, path: path.to_path_buf(), peer_uid, registry: desk_registry()? };
fs::set_permissions(path, fs::Permissions::from_mode(0o660)).with_context(|| format!("setting the mode of {}", path.display()))?;
door.listener.set_nonblocking(true)?;
Ok(door)
}
impl DeskDoor {
pub fn path(&self) -> &Path {
&self.path
}
/// Serves at most `DESK_ACCEPTS_PER_PASS` waiting connections, each to completion, and says
/// how many it took. A refusal or a dropped connection is logged and counted: the loop was
/// busy with it.
pub fn serve_ready<S: MindStore, I: IndexSink<S>>(&mut self, daemon: &mut Daemon<S, I>, now: DateTime<Utc>) -> u32 {
let mut taken = 0;
while taken < DESK_ACCEPTS_PER_PASS {
let stream = match self.listener.accept() {
Ok((stream, _)) => stream,
Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
Err(error) => {
eprintln!("huginn: the desk socket could not accept: {error}");
break;
}
};
taken += 1;
let served = serve_connection(stream, self.peer_uid, daemon, &self.registry, now, |stream| stream.set_nonblocking(false));
if served != Served::Answered {
eprintln!("huginn: {served}");
}
}
taken
}
}
impl Drop for DeskDoor {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
fn profile() -> CultNetTransportProfile {
create_tcp_framed_transport_profile(
"huginn-desk",
TcpFramedTransportProfileOptions { max_payload_bytes: Some(DESK_MAX_REQUEST_BYTES as u32), ..Default::default() },
)
}
/// A stream whose every read and write is bounded by what is left of one deadline: the socket's
/// timeout is reset to the remainder before each call, so bytes arriving do not extend it.
pub(crate) struct Deadlined<St> {
inner: St,
deadline: Instant,
}
impl<St: AsFd> Deadlined<St> {
pub(crate) fn new(inner: St, within: Duration) -> Self {
Self { inner, deadline: Instant::now() + within }
}
fn remaining(&self, which: Timeout) -> io::Result<()> {
// Whole milliseconds: rustix rounds a sub-microsecond tail up into an out-of-range tv_usec.
let left = Duration::from_millis(self.deadline.saturating_duration_since(Instant::now()).as_millis() as u64);
if left.is_zero() {
return Err(io::ErrorKind::TimedOut.into());
}
set_socket_timeout(&self.inner, which, Some(left)).map_err(io::Error::from)
}
}
impl<St: Read + AsFd> Read for Deadlined<St> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.remaining(Timeout::Recv)?;
self.inner.read(buf)
}
}
impl<St: Write + AsFd> Write for Deadlined<St> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.remaining(Timeout::Send)?;
self.inner.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
/// One connection: the peer first, before anything is read, then one frame in, one frame out,
/// all inside `DESK_CONNECTION_DEADLINE`. `ready` readies the stream for blocking I/O and runs
/// only after the peer is known to be the configured one.
pub fn serve_connection<St, S, I>(
stream: St,
peer_uid: u32,
daemon: &mut Daemon<S, I>,
registry: &CultNetSchemaRegistry,
now: DateTime<Utc>,
ready: impl FnOnce(&St) -> io::Result<()>,
) -> Served
where
St: Read + Write + AsFd,
S: MindStore,
I: IndexSink<S>,
{
let uid = match rustix::net::sockopt::socket_peercred(&stream) {
Ok(credentials) => credentials.uid.as_raw(),
Err(error) => return Served::Dropped(format!("the peer's credentials could not be read: {error}")),
};
if uid != peer_uid {
return Served::Refused { uid, expected: peer_uid };
}
if let Err(error) = ready(&stream) {
return Served::Dropped(format!("the stream could not be readied: {error}"));
}
let mut transport = TcpFramedTransportConnection::new(Deadlined::new(stream, DESK_CONNECTION_DEADLINE), profile());
let frame = match transport.receive() {
Ok(frame) => frame,
Err(error) => return Served::Dropped(format!("no whole frame arrived: {error:#}")),
};
let message = match decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0) {
Ok(message) => message,
Err(error) => return Served::Dropped(format!("a frame was not a CultNet message: {error:#}")),
};
let reply = answer_desk_message(daemon, registry, message, now);
let bytes = match encode_cultnet_message_to_vec(&reply, CultNetWireContract::CultNetSchemaV0) {
Ok(bytes) => bytes,
Err(error) => return Served::Dropped(format!("the reply could not be encoded: {error:#}")),
};
match transport.send("schema", &bytes) {
Ok(()) => Served::Answered,
Err(error) => Served::Dropped(format!("the reply was not delivered: {error:#}")),
}
}
/// One message in, one message out. Nothing here reaches a mind except through
/// `Daemon::handle_desk`, and an envelope that does not decode never gets that far.
pub fn answer_desk_message<S: MindStore, I: IndexSink<S>>(
daemon: &mut Daemon<S, I>,
registry: &CultNetSchemaRegistry,
message: CultNetMessage,
now: DateTime<Utc>,
) -> CultNetMessage {
let runtime_id = daemon.runtime_id();
if let Some(field) = overlong_echo(&message) {
return CultNetMessage::Error {
error: format!("{DESK_SERVICE_ID} refuses a {field} over {MAX_ECHOED_FIELD_BYTES} bytes"),
code: None,
details: None,
};
}
match &message {
CultNetMessage::OperationRequest { message_id, operation, .. } => match decode_desk_request(&message) {
Ok((message_id, request)) => {
let operation = request.operation();
let response = daemon.handle_desk(request, now);
respond(&response, &message_id, operation, &runtime_id, DESK_MAX_RESPONSE_BYTES)
}
Err(failure) => encode_desk_failure(message_id, operation, &failure, &runtime_id),
},
CultNetMessage::SchemaCatalogRequest { .. } => match registry.create_catalog_response(&message) {
Ok(reply) => bounded(reply, DESK_MAX_RESPONSE_BYTES, |size| CultNetMessage::Error {
error: match size {
Ok(bytes) => format!("{DESK_SERVICE_ID} refuses a catalog reply of {bytes} bytes over {DESK_MAX_RESPONSE_BYTES}"),
Err(detail) => detail,
},
code: None,
details: None,
}),
Err(error) => CultNetMessage::Error { error: format!("{error:#}"), code: None, details: None },
},
_ => CultNetMessage::Error {
error: format!("{DESK_SERVICE_ID} answers cultnet.operation_request.v0 and cultnet.schema_catalog_request.v0"),
code: None,
details: None,
},
}
}
/// One answer as its message, or, past `limit` encoded, the refusal that names the size.
pub(crate) fn respond(response: &DeskResponse, message_id: &str, operation: &str, runtime_id: &str, limit: usize) -> CultNetMessage {
let not_encodable = |detail: String| {
encode_desk_failure(message_id, operation, &OperationFailure { code: "response-not-encodable".into(), message: detail }, runtime_id)
};
let reply = match encode_desk_response(message_id, operation, response, runtime_id) {
Ok(reply) => reply,
Err(error) => return not_encodable(format!("{error:#}")),
};
bounded(reply, limit, |size| match size {
Ok(bytes) => {
// A read of one fork past the cap is that fork's refusal; every other reply is the generic one.
let refusal = match response {
DeskResponse::Fork { entry, .. } => DeskResponse::NotStored(fork_too_large_read(entry.card.fork_id.as_str())),
_ => DeskResponse::Refused(MindRefusal::ResponseTooLarge { bytes: bytes as u64, limit: limit as u64 }),
};
encode_desk_response(message_id, operation, &refusal, runtime_id).unwrap_or_else(|error| not_encodable(format!("{error:#}")))
}
Err(detail) => not_encodable(detail),
})
}
/// `reply` itself when it encodes to at most `limit` bytes. Otherwise what `refuse` makes of the
/// encoded size, or of the encoding error. Every reply the door sends passes here.
fn bounded(reply: CultNetMessage, limit: usize, refuse: impl FnOnce(Result<usize, String>) -> CultNetMessage) -> CultNetMessage {
match encode_cultnet_message_to_vec(&reply, CultNetWireContract::CultNetSchemaV0) {
Ok(bytes) if bytes.len() <= limit => reply,
Ok(bytes) => refuse(Ok(bytes.len())),
Err(error) => refuse(Err(format!("{error:#}"))),
}
}

File diff suppressed because it is too large Load diff

View file

@ -15,12 +15,17 @@
pub mod bodies;
pub mod daemon;
pub mod desk_door;
pub mod index;
pub mod serve;
pub mod sources;
#[cfg(test)]
mod desk_door_tests;
#[cfg(test)]
mod semantic_tests;
pub use daemon::{Daemon, Found, Handled, Hits, IndexSink, Search, SearchTicket, runtime_id};
pub use serve::{Options, ServeOptions, parse_options, run, startup};
pub use desk_door::DeskDoor;
pub use serve::{Options, ServeOptions, Started, parse_options, run, startup};

View file

@ -9,13 +9,13 @@ use anyhow::{Context, Result};
use huginn_daemon::index::Backoff;
use huginn_daemon::index::ollama::pool;
use huginn_daemon::index::qdrant::QdrantIndex;
use huginn_daemon::serve::{ServeOptions, parse_options, run, startup};
use huginn_daemon::serve::{ServeOptions, Started, parse_options, run, startup};
fn main() -> Result<()> {
let options = parse_options(std::env::args().skip(1))?;
let embedder = pool(&options.ollama_urls, &options.reserve_ollama_urls, &options.embedding_model);
let index = QdrantIndex::new(&options.qdrant_url);
let (mut daemon, mut hub, registry) = match startup(&options, embedder, index, Backoff::default()) {
let Started { mut daemon, mut hub, registry, mut desk } = match startup(&options, embedder, index, Backoff::default()) {
Ok(started) => started,
Err(error) => {
eprintln!("huginn-daemon refuses to serve {}: {error:#}", options.instance.0);
@ -33,5 +33,8 @@ fn main() -> Result<()> {
}
eprintln!("huginn-daemon serving {} on {}", options.instance.0, hub.local_addr()?);
run(&mut daemon, &mut hub, &registry, &stopping, &ServeOptions::default())
if let Some(door) = &desk {
eprintln!("huginn-daemon serving the desk door on {}", door.path().display());
}
run(&mut daemon, &mut hub, &registry, desk.as_mut(), &stopping, &ServeOptions::default())
}

View file

@ -176,7 +176,7 @@ impl Harness {
let stopping = Arc::new(AtomicBool::new(false));
let loop_stopping = Arc::clone(&stopping);
let options = ServeOptions::default();
let serving = std::thread::spawn(move || run(&mut daemon, &mut hub, &registry, &loop_stopping, &options));
let serving = std::thread::spawn(move || run(&mut daemon, &mut hub, &registry, None, &loop_stopping, &options));
Self { endpoint, stopping, serving: Some(serving), embedder, index, _root: root }
}

View file

@ -79,6 +79,7 @@ use crate::bodies::{
DEFERRED_BUDGET_BYTES, DEFERRED_CHUNK_BYTES, DeferredBodies,
};
use crate::daemon::{Daemon, Handled, IndexSink, Search, SearchTicket, runtime_id};
use crate::desk_door::{DeskDoor, bind_desk};
use huginn_mind::envelope::{OperationFailure, decode_request, encode_failure, encode_response};
use huginn_mind::MAX_DEFERRED_BODY_BYTES;
use crate::index::{Backoff, Embedder, VectorIndex, WorkerSink};
@ -118,12 +119,22 @@ pub struct Options {
pub embedding_model: String,
/// The forge a persona's activity read goes to; absent, that read is refused.
pub forge_url: Option<String>,
/// The desk door: the unix socket Bifrost's receiver answers on, and the one uid that may
/// speak on it. Absent, the daemon serves the RUDP door alone.
pub desk: Option<DeskOptions>,
}
/// Where the desk door listens and who may speak there.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeskOptions {
pub socket: PathBuf,
pub peer_uid: u32,
}
/// Exactly `--state-root`, `--instance`, `--bind`, `--qdrant-url`,
/// `--ollama-url` and `--embedding-model`, each required, each once, except
/// `--ollama-url`, which repeats for each GPU host, and the optional
/// `--reserve-ollama-url`, which repeats for each CPU host, and the optional `--forge-url`, once. The URLs are plain
/// `--reserve-ollama-url`, which repeats for each CPU host, and the optional `--forge-url`, once, and the optional pair `--desk-socket` (an absolute path) and `--desk-peer-uid` (a uid other than 0), both or neither, each once. The URLs are plain
/// `http://`: this build carries no TLS.
pub fn parse_options(args: impl Iterator<Item = String>) -> Result<Options> {
let mut args = args;
@ -132,7 +143,7 @@ pub fn parse_options(args: impl Iterator<Item = String>) -> Result<Options> {
while let Some(name) = args.next() {
let name = name.strip_prefix("--").with_context(|| format!("expected --option, got {name:?}"))?;
ensure!(
matches!(name, "state-root" | "instance" | "bind" | "qdrant-url" | "ollama-url" | "reserve-ollama-url" | "embedding-model" | "forge-url"),
matches!(name, "state-root" | "instance" | "bind" | "qdrant-url" | "ollama-url" | "reserve-ollama-url" | "embedding-model" | "forge-url" | "desk-socket" | "desk-peer-uid"),
"unsupported Huginn option --{name}"
);
let value = args.next().with_context(|| format!("missing value for --{name}"))?;
@ -168,6 +179,17 @@ pub fn parse_options(args: impl Iterator<Item = String>) -> Result<Options> {
reserve_ollama_urls,
embedding_model,
forge_url: values.contains_key("forge-url").then(|| url("forge-url")).transpose()?,
desk: match (values.get("desk-socket"), values.get("desk-peer-uid")) {
(None, None) => None,
(Some(socket), Some(uid)) => {
let socket = PathBuf::from(socket);
ensure!(socket.is_absolute(), "--desk-socket must be an absolute path");
let peer_uid: u32 = uid.parse().with_context(|| format!("--desk-peer-uid must be a uid, got {uid:?}"))?;
ensure!(peer_uid != 0, "--desk-peer-uid must not be 0");
Some(DeskOptions { socket, peer_uid })
}
_ => anyhow::bail!("--desk-socket and --desk-peer-uid are given together or not at all"),
},
})
}
@ -177,18 +199,14 @@ pub fn parse_options(args: impl Iterator<Item = String>) -> Result<Options> {
/// of the refusal and nothing reaches the vector store for a process that
/// never listens. The worker's first act is to reconcile the collection
/// against every indexable document the mind holds now.
pub fn startup<E, V>(
options: &Options,
embedder: E,
index: V,
backoff: Backoff,
) -> Result<(Daemon<OwnedRedbMessagePackBackingStore, WorkerSink>, CultNetRudpServerHub, CultNetSchemaRegistry)>
pub fn startup<E, V>(options: &Options, embedder: E, index: V, backoff: Backoff) -> Result<Started>
where
E: Embedder + Send + 'static,
V: VectorIndex + crate::sources::SourceIndex + Send + 'static,
{
let mind = Mind::open(&options.state_root, &options.instance)?;
let hub = bind(options.bind, &runtime_id(&options.instance))?;
let desk = options.desk.as_ref().map(|desk| bind_desk(&desk.socket, desk.peer_uid)).transpose()?;
let startup_entries = mind.index_entries(None)?;
let sink = WorkerSink::spawn(embedder, index, &options.instance, mind.genesis_receipt_id()?, startup_entries, backoff);
let daemon = Daemon::new(mind, sink);
@ -196,7 +214,16 @@ where
Some(url) => daemon.with_forge(crate::sources::forge::HttpForge::new(url)),
None => daemon,
};
Ok((daemon, hub, schema_registry()?))
Ok(Started { daemon, hub, registry: schema_registry()?, desk })
}
/// What `startup` hands the loop: the daemon over the opened mind, the bound RUDP hub, the
/// catalog the RUDP door advertises, and the desk door when one was asked for.
pub struct Started {
pub daemon: Daemon<OwnedRedbMessagePackBackingStore, WorkerSink>,
pub hub: CultNetRudpServerHub,
pub registry: CultNetSchemaRegistry,
pub desk: Option<DeskDoor>,
}
/// The bytes one reliable packet carries, and the packets one session may hold
@ -255,7 +282,7 @@ pub const MAX_ECHOED_FIELD_BYTES: usize = 256;
/// The first field of a request that a reply would echo and that is over
/// `MAX_ECHOED_FIELD_BYTES`.
fn overlong_echo(message: &CultNetMessage) -> Option<&'static str> {
pub(crate) fn overlong_echo(message: &CultNetMessage) -> Option<&'static str> {
let echoed: Vec<(&'static str, &str)> = match message {
CultNetMessage::OperationRequest { message_id, service_id, operation, payload_schema, .. } => {
vec![
@ -479,6 +506,7 @@ pub fn run<S: MindStore, I: IndexSink<S>>(
daemon: &mut Daemon<S, I>,
hub: &mut CultNetRudpServerHub,
registry: &CultNetSchemaRegistry,
mut desk: Option<&mut DeskDoor>,
stopping: &AtomicBool,
options: &ServeOptions,
) -> Result<()> {
@ -523,6 +551,7 @@ pub fn run<S: MindStore, I: IndexSink<S>>(
}
served += 1;
}
let desk_served = desk.as_deref_mut().map_or(0, |door| door.serve_ready(daemon, Utc::now()));
let finished = collect_searches(daemon, &waiting);
let answered = !finished.is_empty();
for (ticket, found) in finished {
@ -531,7 +560,7 @@ pub fn run<S: MindStore, I: IndexSink<S>>(
let reply = respond(&response, &asked.message_id, asked.operation, &daemon.runtime_id(), &mut bodies, Utc::now());
send(hub, &asked.session, &reply);
}
if served == 0 && !answered {
if served == 0 && desk_served == 0 && !answered {
std::thread::sleep(options.idle_sleep);
}
}
@ -756,7 +785,7 @@ mod tests {
let loop_stopping = Arc::clone(&stopping);
let serving = std::thread::spawn(move || {
let result = run(&mut daemon, &mut hub, &registry, &loop_stopping, &ServeOptions::default());
let result = run(&mut daemon, &mut hub, &registry, None, &loop_stopping, &ServeOptions::default());
(result, daemon)
});
@ -863,7 +892,7 @@ mod tests {
let stopping = Arc::new(AtomicBool::new(false));
let loop_stopping = Arc::clone(&stopping);
let serving = std::thread::spawn(move || {
let result = run(&mut daemon, &mut hub, &registry, &loop_stopping, &ServeOptions::default());
let result = run(&mut daemon, &mut hub, &registry, None, &loop_stopping, &ServeOptions::default());
(result, daemon)
});
@ -1104,7 +1133,7 @@ mod tests {
let stopping = Arc::new(AtomicBool::new(false));
let loop_stopping = Arc::clone(&stopping);
let serving = std::thread::spawn(move || {
let result = run(&mut daemon, &mut hub, &registry, &loop_stopping, &ServeOptions::default());
let result = run(&mut daemon, &mut hub, &registry, None, &loop_stopping, &ServeOptions::default());
(result, daemon)
});
@ -1541,6 +1570,7 @@ mod tests {
reserve_ollama_urls: Vec::new(),
embedding_model: "a-model".into(),
forge_url: None,
desk: None,
};
let started = |options: &Options| {
let (embedder, index) = crate::index::fakes::pair();
@ -1556,8 +1586,8 @@ mod tests {
let error = format!("{:#}", started(&options).err().expect("a held port refuses"));
assert!(error.contains("binding"), "{error}");
drop(occupied);
let (_daemon, hub, _registry) = started(&options).expect("both gates open");
assert_eq!(hub.local_addr().unwrap(), port);
let started = started(&options).expect("both gates open");
assert_eq!(started.hub.local_addr().unwrap(), port);
}
#[test]
@ -1579,6 +1609,7 @@ mod tests {
reserve_ollama_urls: Vec::new(),
embedding_model: "qwen3-embedding:0.6b".into(),
forge_url: None,
desk: None,
}
);
// Each of the six is required: there is no index-less mode.

View file

@ -0,0 +1,913 @@
//! The desk door through the built binary: a real `huginn-daemon` process, its two sockets and its
//! mind on disk. What the unit and pipeline smokes cannot show: the flags reach the door, the
//! door answers beside the RUDP door in one process, a stopped daemon takes its socket with it,
//! a leftover socket is replaced, a retry after a restart replays the stored answer, and a peer
//! that is not the configured uid is refused (logged by name) with the mind untouched.
//!
//! Needs a non-root uid: the door refuses uid 0.
use std::io::{Read, Write};
use std::net::UdpSocket;
use std::os::unix::fs::MetadataExt;
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use chrono::{TimeZone, Utc};
use cultnet_rs::{
CULTNET_OPERATION_CONNECTION_ID, CultMesh, CultMeshRudpSocketOptions, CultNetMessage, CultNetWireContract,
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, encode_frame,
};
use huginn_mind::desk_wire::{DeskRequest, DeskResponse, ForkAnswer, ForkAnswerCode, ForkAnswerRequest, RequestSchema};
use huginn_mind::envelope::{decode_desk_response, decode_response, encode_desk_request, encode_request};
use huginn_mind::eureka_pipeline::{
Date, DeskAuthority, DocRef, OrgRepo, PipelineCampaign, PipelineDocument, PipelineInstance, PipelineQuestion, PipelineStewardship,
QuestionOption, Sha, Short, Title,
};
use huginn_mind::wire::{HuginnMindRequest, HuginnMindResponse};
use huginn_mind::{Faculty, Mind, PipelineAdmissionBatch, PipelineAdmissionOutcome, PipelineProvenance, options_hash};
const INSTANCE: &str = "yggdrasil";
const FORK: &str = "eureka-state:question:Q1";
const R1: &str = "00000000-0000-4000-8000-000000000001";
const R2: &str = "00000000-0000-4000-8000-000000000002";
/// These tests run one at a time: each forks daemons and times them, and a fork made while another
/// test holds a mind open lends that child the mind's lock until it execs.
fn serial() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn my_uid() -> u32 {
let dir = tempfile::tempdir().unwrap();
std::fs::metadata(dir.path()).unwrap().uid()
}
fn short(value: &str) -> Short {
Short(value.into())
}
/// A mind holding the campaign and one answerable question; returns the question's `options_hash`.
fn seed(root: &Path) -> String {
let mut mind = Mind::open(root, &INSTANCE.into()).unwrap();
let question = PipelineQuestion {
campaign: "eureka-state".into(),
label: "Q1".into(),
title: Title("Who owns the state?".into()),
question: "Who owns the state?".into(),
options: vec![
QuestionOption { label: "A".into(), text: "the organ".into() },
QuestionOption { label: "B".into(), text: "the client".into() },
],
recommended: "A".into(),
depends: vec![],
raised_in: None,
asked_on: Date("2026-09-16".into()),
};
let hash = options_hash(&question).0;
let repo = OrgRepo("GameCult/Epiphany".into());
let documents = vec![
PipelineDocument::Instance(PipelineInstance {
instance: INSTANCE.into(),
display_name: short("yggdrasil mind"),
created_at: Date("2026-09-16".into()),
host: short(INSTANCE),
}),
PipelineDocument::Stewardship(PipelineStewardship {
instance: INSTANCE.into(),
repo: repo.clone(),
sequence: 1,
assigned_on: Date("2026-09-16".into()),
note: "assigned".into(),
names: vec![],
}),
PipelineDocument::Campaign(PipelineCampaign {
slug: "eureka-state".into(),
title: Title("Eureka pipeline state".into()),
repos: vec![repo],
working_branch: short("codex/eureka-pipeline-state"),
target_doc: DocRef { path: short("notes/target.md"), start_line: 1, end_line: 9, commit: Sha("5f98228d".into()) },
}),
PipelineDocument::Question(question),
];
let provenance = PipelineProvenance {
faculty: Faculty::Hands,
agent: short("claude"),
session: short("session-1"),
tool: short("admit"),
imported_from: None,
};
let batch = PipelineAdmissionBatch { instance: INSTANCE.into(), provenance, documents };
let outcome = mind.admit(batch, Utc.with_ymd_and_hms(2026, 9, 16, 12, 0, 0).unwrap());
assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}");
hash
}
/// A daemon process over `root`, its RUDP door on a free local port, its desk door at `desk`.
struct Daemon {
child: Child,
rudp: String,
log: PathBuf,
}
fn free_port() -> u16 {
UdpSocket::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
}
fn spawn(root: &Path, desk: &Path, peer_uid: u32, log: &Path) -> Daemon {
let port = free_port();
let child = Command::new(env!("CARGO_BIN_EXE_huginn-daemon"))
.args(["--state-root", root.to_str().unwrap(), "--instance", INSTANCE, "--bind", &format!("127.0.0.1:{port}")])
.args(["--qdrant-url", "http://127.0.0.1:1", "--ollama-url", "http://127.0.0.1:1", "--embedding-model", "a-model"])
.args(["--desk-socket", desk.to_str().unwrap(), "--desk-peer-uid", &peer_uid.to_string()])
.stdout(Stdio::null())
.stderr(std::fs::OpenOptions::new().create(true).append(true).open(log).unwrap())
.spawn()
.unwrap();
let mut daemon = Daemon { child, rudp: format!("rudp://127.0.0.1:{port}"), log: log.to_path_buf() };
let started = Instant::now();
// Listening, not merely present: a killed predecessor leaves a socket nobody answers on.
while UnixStream::connect(desk).is_err() {
assert!(daemon.child.try_wait().unwrap().is_none(), "the daemon exited early:\n{}", daemon.log_text());
assert!(started.elapsed() < Duration::from_secs(30), "the desk socket never appeared:\n{}", daemon.log_text());
std::thread::sleep(Duration::from_millis(20));
}
daemon
}
impl Daemon {
fn log_text(&self) -> String {
std::fs::read_to_string(&self.log).unwrap_or_default()
}
fn signal(&mut self, name: &str) {
let status = Command::new("kill").args([&format!("-{name}"), &self.child.id().to_string()]).status().unwrap();
assert!(status.success());
let started = Instant::now();
while self.child.try_wait().unwrap().is_none() {
assert!(started.elapsed() < Duration::from_secs(20), "the daemon did not stop on {name}");
std::thread::sleep(Duration::from_millis(20));
}
}
fn documents(&self) -> u32 {
let mut client = CultMesh::create_rudp_client_for_endpoint(
"desk-e2e".to_string(),
CULTNET_OPERATION_CONNECTION_ID,
&self.rudp,
CultMeshRudpSocketOptions::default(),
)
.unwrap();
client.connect(Vec::new()).unwrap();
for _ in 0..2000 {
let _ = client.receive_once();
if client.connected() {
break;
}
std::thread::sleep(Duration::from_millis(2));
}
assert!(client.connected(), "the RUDP door never accepted:\n{}", self.log_text());
client.send_schema_message(&encode_request("w-1", &HuginnMindRequest::Whoami, None).unwrap()).unwrap();
for _ in 0..2000 {
client.poll_resends().unwrap();
if let Some(reply) = client.receive_schema_message_once().unwrap() {
return match decode_response(&reply).unwrap().1.unwrap() {
HuginnMindResponse::Whoami(status) => status.documents,
other => panic!("{other:?}"),
};
}
std::thread::sleep(Duration::from_millis(2));
}
panic!("whoami was not answered");
}
}
impl Drop for Daemon {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn answer(who: &str, hash: &str, request_id: &str) -> DeskRequest {
DeskRequest::Answer {
mind: INSTANCE.into(),
subject: short(who),
authority: DeskAuthority::Operator,
deciders: vec![short(who)],
request: ForkAnswerRequest {
schema_version: RequestSchema::V0,
fork_id: FORK.into(),
options_hash: hash.into(),
answer: Some(ForkAnswer { choice: Some("A".into()), note: None }),
retracted: false,
retracts: None,
answered_at: "2026-09-16T11:00:00Z".into(),
request_id: request_id.into(),
},
}
}
fn forks_request(who: &str, deciders: &[&str]) -> DeskRequest {
DeskRequest::Forks {
mind: INSTANCE.into(),
subject: short(who),
deciders: deciders.iter().map(|d| short(d)).collect(),
status: None,
cursor: None,
limit: huginn_mind::desk_wire::ForkLimit::new(50).unwrap(),
}
}
fn framed(message: &CultNetMessage) -> Vec<u8> {
encode_frame(&encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0).unwrap()).unwrap()
}
fn desk_bytes(id: &str, request: &DeskRequest) -> Vec<u8> {
framed(&encode_desk_request(id, request, None).unwrap())
}
fn read_reply(stream: &mut impl Read) -> Option<CultNetMessage> {
let mut header = [0_u8; 4];
stream.read_exact(&mut header).ok()?;
let mut payload = vec![0_u8; u32::from_be_bytes(header) as usize];
stream.read_exact(&mut payload).ok()?;
Some(decode_cultnet_message_from_slice(&payload, CultNetWireContract::CultNetSchemaV0).unwrap())
}
fn exchange(path: &Path, bytes: &[u8]) -> Option<CultNetMessage> {
let mut stream = UnixStream::connect(path).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let _ = stream.write_all(bytes);
read_reply(&mut stream)
}
fn ask(path: &Path, id: &str, request: &DeskRequest) -> DeskResponse {
let reply = exchange(path, &desk_bytes(id, request)).expect("the door replied");
decode_desk_response(&reply).unwrap().1.unwrap_or_else(|failure| panic!("{failure:?}"))
}
fn counted_id(response: DeskResponse) -> String {
match response {
DeskResponse::Answered { answer, .. } => {
assert_eq!(answer.outcome, "Counted", "{answer:?}");
answer.answer_id
}
other => panic!("{other:?}"),
}
}
fn catalog_request() -> CultNetMessage {
CultNetMessage::SchemaCatalogRequest { message_id: "c-1".into(), include_schema_json: Some(false), schema_ids: None, kinds: None }
}
#[test]
fn the_binary_serves_both_doors_and_survives_restart() {
let _serial = serial();
assert_ne!(my_uid(), 0, "the door refuses uid 0, so this test cannot run as root");
let (root, run) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let hash = seed(root.path());
let (desk, log) = (run.path().join("desk.sock"), run.path().join("daemon.log"));
let mut daemon = spawn(root.path(), &desk, my_uid(), &log);
let seeded = daemon.documents();
// The catalog is the two desk schemas.
let catalog = format!("{:?}", exchange(&desk, &framed(&catalog_request())).expect("a catalog"));
assert!(catalog.contains("huginn.desk_request.v1") && catalog.contains("huginn.desk_response.v1"), "{catalog}");
assert!(!catalog.contains("huginn.mind_request.v1"), "{catalog}");
// The reads answer, store nothing, and carry the whole record to a subject the deciders do not name.
let (list, listed_at) = match ask(&desk, "m-r1", &forks_request("x", &["x"])) {
DeskResponse::Forks { list, as_of } => (list, as_of),
other => panic!("{other:?}"),
};
assert_eq!((list.open_count, list.recorded_count, list.forks.len(), list.stamp.clone()), (1, 0, 1, format!("s{listed_at}")));
assert_eq!(list.forks[0].card.options_hash, hash);
let DeskResponse::Fork { entry, as_of } = ask(&desk, "m-r2", &DeskRequest::Fork { mind: INSTANCE.into(), subject: short("x"), deciders: vec![short("x")], fork_id: FORK.into() })
else {
panic!("a read of the seeded fork")
};
assert_eq!((entry, as_of), (list.forks[0].clone(), listed_at));
assert_eq!(daemon.documents(), seeded);
// `authority` is no field of a read: the payload is refused whole and nothing is answered or stored.
for (operation, request) in [("forks", forks_request("x", &["x"])), ("fork", DeskRequest::Fork { mind: INSTANCE.into(), subject: short("x"), deciders: vec![short("x")], fork_id: FORK.into() })] {
let mut value = serde_json::to_value(&request).unwrap();
let variant = if operation == "forks" { "Forks" } else { "Fork" };
value[variant]["authority"] = serde_json::json!("Operator");
let message = CultNetMessage::OperationRequest {
message_id: "m-ra".into(),
service_id: huginn_mind::desk_wire::DESK_SERVICE_ID.into(),
operation: operation.into(),
payload_schema: huginn_mind::desk_wire::DESK_REQUEST_SCHEMA.into(),
payload_encoding: "messagepack-base64".into(),
payload: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, rmp_serde::to_vec_named(&value).unwrap()),
source_runtime_id: None,
target_runtime_id: None,
};
let reply = exchange(&desk, &framed(&message)).expect("the door replied");
let failure = decode_desk_response(&reply).unwrap().1.unwrap_err();
assert_eq!(failure.code, "payload-not-a-request", "{operation}");
}
assert_eq!(daemon.documents(), seeded);
// An answer, then a stale one.
let first = answer("x", &hash, R1);
assert_eq!(counted_id(ask(&desk, "m-1", &first)), "n1");
let DeskResponse::Answered { answer: stale, counted_answer_id, .. } = ask(&desk, "m-2", &answer("x", &"0".repeat(64), R2)) else {
panic!("a stale answer is stored")
};
assert_eq!((stale.code, counted_answer_id.as_deref()), (Some(ForkAnswerCode::StaleFork), Some("n1")));
let stored = daemon.documents();
assert_eq!(stored, seeded + 2);
// A subject the call's deciders do not name reads both answers, the first with its choice and the stale one with its code.
let DeskResponse::Fork { entry, .. } =
ask(&desk, "m-r3", &DeskRequest::Fork { mind: INSTANCE.into(), subject: short("outsider"), deciders: vec![short("y")], fork_id: FORK.into() })
else {
panic!("a read")
};
let seen: Vec<_> = entry.record.answers.iter().map(|answer| (answer.answer_id.as_str(), answer.answerer.as_str(), answer.code)).collect();
assert_eq!(seen, [("n1", "x", None), ("n2", "x", Some(ForkAnswerCode::StaleFork))]);
assert_eq!((entry.record.counted_answer_id.as_deref(), entry.card.deciders.as_slice()), (Some("n1"), ["y".to_string()].as_slice()));
assert_eq!(daemon.documents(), stored);
// A stop takes the socket with it; the stored answers are the retry's memory.
daemon.signal("TERM");
assert!(!desk.exists(), "a stopped daemon unlinks its socket");
let mut daemon = spawn(root.path(), &desk, my_uid(), &log);
assert_eq!(daemon.documents(), stored);
assert_eq!(counted_id(ask(&desk, "m-1", &first)), "n1", "the same request id returns the stored answer");
assert_eq!(daemon.documents(), stored, "the replay stored nothing");
// A killed daemon leaves its socket; the next one replaces it.
daemon.signal("KILL");
assert!(desk.exists(), "a killed daemon leaves its socket behind");
let daemon = spawn(root.path(), &desk, my_uid(), &log);
assert_eq!(counted_id(ask(&desk, "m-1", &first)), "n1");
assert_eq!(daemon.documents(), stored);
}
#[test]
fn the_binary_refuses_another_uid() {
let _serial = serial();
let mine = my_uid();
assert_ne!(mine, 0);
let (root, run) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let hash = seed(root.path());
let (desk, log) = (run.path().join("desk.sock"), run.path().join("daemon.log"));
let mut daemon = spawn(root.path(), &desk, mine + 1, &log);
let seeded = daemon.documents();
assert!(exchange(&desk, &desk_bytes("m-1", &answer("x", &hash, R1))).is_none(), "a refused peer reads EOF");
assert!(exchange(&desk, &framed(&catalog_request())).is_none(), "not even the catalog");
assert_eq!(daemon.documents(), seeded);
daemon.signal("TERM");
let wanted = format!("desk door refused a peer with uid {mine} (expects {})", mine + 1);
assert!(daemon.log_text().contains(&wanted), "the log names both uids:\n{}", daemon.log_text());
// Read-only reopen: the mind is as it was.
let reopened = Mind::open(root.path(), &INSTANCE.into()).unwrap();
assert_eq!(reopened.status(huginn_mind::wire::IndexStatus::Current).documents, seeded);
}
/// Soul's probe, from the built binary: a client that is uid 0 inside its own user namespace is
/// the real uid to the kernel, so the door sees the real uid and namespace root cannot pass as root.
#[test]
fn namespace_root_is_seen_as_the_real_uid() {
let _serial = serial();
let probe = Command::new("unshare").args(["-r", "true"]).status();
if !probe.is_ok_and(|status| status.success()) || Command::new("socat").arg("-V").stdout(Stdio::null()).status().is_err() {
eprintln!("skipped: needs `unshare -r` (user namespaces) and socat");
return;
}
let mine = my_uid();
assert_ne!(mine, 0);
let (root, run) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
seed(root.path());
let (desk, log) = (run.path().join("desk.sock"), run.path().join("daemon.log"));
let frame = run.path().join("catalog.frame");
std::fs::write(&frame, framed(&catalog_request())).unwrap();
let through_namespace = || {
Command::new("unshare")
.args(["-r", "socat", "-t", "2", "-", &format!("UNIX-CONNECT:{}", desk.display())])
.stdin(std::fs::File::open(&frame).unwrap())
.output()
.unwrap()
.stdout
};
let mut allowed = spawn(root.path(), &desk, mine, &log);
let reply = through_namespace();
assert!(!reply.is_empty(), "the real uid is the configured one, so namespace root is answered:\n{}", allowed.log_text());
allowed.signal("TERM");
let mut other = spawn(root.path(), &desk, mine + 1, &log);
assert!(through_namespace().is_empty(), "a door configured for another uid answers nobody");
other.signal("TERM");
let text = other.log_text();
assert!(text.contains(&format!("desk door refused a peer with uid {mine} (expects {})", mine + 1)), "{text}");
assert!(!text.contains("refused a peer with uid 0"), "the door never sees namespace root as 0:\n{text}");
}
// ---- the loop's bounds, from the built binary and from run() in process ----
fn setup() -> (tempfile::TempDir, tempfile::TempDir, String, PathBuf, PathBuf) {
let (root, run) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let hash = seed(root.path());
let (desk, log) = (run.path().join("desk.sock"), run.path().join("daemon.log"));
(root, run, hash, desk, log)
}
/// A peer that sends a valid frame one byte per 300 ms: inside any per-read timeout, so only a
/// per-connection deadline cuts it off. Returns when the door has closed on it, with the bytes sent.
fn drip(desk: &Path, hash: &str) -> std::thread::JoinHandle<(usize, Duration)> {
let (desk, bytes) = (desk.to_path_buf(), desk_bytes("m-1", &answer("x", hash, R1)));
std::thread::spawn(move || {
let mut stream = UnixStream::connect(&desk).unwrap();
let started = Instant::now();
let mut sent = 0;
for byte in bytes.iter().take(60) {
if stream.write_all(&[*byte]).is_err() {
break;
}
sent += 1;
std::thread::sleep(Duration::from_millis(300));
}
(sent, started.elapsed())
})
}
/// The bound the door states for one connection is one second, so a dripping peer neither delays
/// another door's answer nor a stop beyond it (the sum is checked with the margin a loaded host needs).
#[test]
fn a_dripping_peer_does_not_delay_the_rudp_door() {
let _serial = serial();
let (root, _run, hash, desk, log) = setup();
let daemon = spawn(root.path(), &desk, my_uid(), &log);
let dripper = drip(&desk, &hash);
std::thread::sleep(Duration::from_millis(400));
let asked = Instant::now();
assert!(daemon.documents() > 0);
assert!(asked.elapsed() < Duration::from_millis(2500), "the RUDP whoami waited {:?} behind a drip", asked.elapsed());
let (sent, lasted) = dripper.join().unwrap();
assert!(lasted < Duration::from_millis(1800), "the drip was held for {lasted:?} ({sent} bytes), not cut at one second");
}
#[test]
fn a_dripping_peer_does_not_delay_a_stop() {
let _serial = serial();
let (root, _run, hash, desk, log) = setup();
let mut daemon = spawn(root.path(), &desk, my_uid(), &log);
let dripper = drip(&desk, &hash);
std::thread::sleep(Duration::from_millis(300));
let signalled = Instant::now();
let status = Command::new("kill").args(["-TERM", &daemon.child.id().to_string()]).status().unwrap();
assert!(status.success());
while daemon.child.try_wait().unwrap().is_none() {
assert!(signalled.elapsed() < Duration::from_millis(2500), "the daemon was still held by a drip {:?} after SIGTERM", signalled.elapsed());
std::thread::sleep(Duration::from_millis(20));
}
dripper.join().unwrap();
}
fn ticks(pid: u32) -> u64 {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).unwrap();
let rest = &stat[stat.rfind(')').unwrap() + 2..];
let fields: Vec<&str> = rest.split(' ').collect();
fields[11].parse::<u64>().unwrap() + fields[12].parse::<u64>().unwrap()
}
/// An idle daemon sleeps between passes: a loop that spins when nothing waits burns the core.
#[test]
fn an_idle_daemon_does_not_spin() {
let _serial = serial();
let (root, _run, _hash, desk, log) = setup();
let mut daemon = spawn(root.path(), &desk, my_uid(), &log);
std::thread::sleep(Duration::from_millis(1500));
let before = ticks(daemon.child.id());
std::thread::sleep(Duration::from_secs(2));
let burned = ticks(daemon.child.id()) - before;
// 100 ticks a second: a spinning loop burns about 200 in two seconds, an idle one a handful.
assert!(burned < 30, "an idle daemon burned {burned} ticks in 2 s");
daemon.signal("TERM");
}
/// WouldBlock on an idle listener is not an error and is not logged; a real accept error is.
#[test]
fn an_idle_door_logs_no_accept_error_and_a_real_one_is_logged() {
let _serial = serial();
let (root, _run, _hash, desk, log) = setup();
let mut daemon = spawn(root.path(), &desk, my_uid(), &log);
std::thread::sleep(Duration::from_millis(500));
assert!(!daemon.log_text().contains("could not accept"), "an idle door logged:\n{}", daemon.log_text());
// An accept error: the daemon's soft fd limit is 3, stdin, stdout and stderr, so no
// descriptor number is free whatever else it holds open; then a peer connects.
let pid = daemon.child.id();
let limit = |soft: usize| {
let status = Command::new("prlimit").args([&format!("--pid={pid}"), &format!("--nofile={soft}:4096")]).status();
status.is_ok_and(|status| status.success())
};
if !limit(3) {
eprintln!("skipped: needs prlimit");
return;
}
let _peer = UnixStream::connect(&desk).unwrap();
let started = Instant::now();
while !daemon.log_text().contains("could not accept") && started.elapsed() < Duration::from_secs(5) {
std::thread::sleep(Duration::from_millis(20));
}
limit(1024);
assert!(daemon.log_text().contains("could not accept"), "an accept error went unlogged:\n{}", daemon.log_text());
daemon.signal("TERM");
}
/// A pass that served desk connections is followed at once by the next pass, not by the idle
/// sleep: eight queued connections are answered in two passes of four, with no sleep between.
#[test]
fn a_busy_pass_is_not_followed_by_the_idle_sleep() {
let _serial = serial();
use huginn_daemon::index::Backoff;
use huginn_daemon::index::ollama::pool;
use huginn_daemon::index::qdrant::QdrantIndex;
use huginn_daemon::serve::{ServeOptions, Started, parse_options, run, startup};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let (root, _run, _hash, desk, _log) = setup();
let args = [
"--state-root", root.path().to_str().unwrap(), "--instance", INSTANCE, "--bind", &format!("127.0.0.1:{}", free_port()),
"--qdrant-url", "http://127.0.0.1:1", "--ollama-url", "http://127.0.0.1:1", "--embedding-model", "a-model",
"--desk-socket", desk.to_str().unwrap(), "--desk-peer-uid", &my_uid().to_string(),
];
let options = parse_options(args.iter().map(|arg| arg.to_string())).unwrap();
let embedder = pool(&options.ollama_urls, &options.reserve_ollama_urls, &options.embedding_model);
let Started { mut daemon, mut hub, registry, desk: mut door } =
startup(&options, embedder, QdrantIndex::new(&options.qdrant_url), Backoff::default()).unwrap();
let stopping = Arc::new(AtomicBool::new(false));
let loop_stopping = Arc::clone(&stopping);
let serving = std::thread::spawn(move || {
let serve = ServeOptions { idle_sleep: Duration::from_millis(600), ..ServeOptions::default() };
run(&mut daemon, &mut hub, &registry, door.as_mut(), &loop_stopping, &serve)
});
std::thread::sleep(Duration::from_millis(100));
let frame = framed(&catalog_request());
let mut peers: Vec<UnixStream> = (0..8)
.map(|_| {
let mut peer = UnixStream::connect(&desk).unwrap();
peer.write_all(&frame).unwrap();
peer.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
peer
})
.collect();
let started = Instant::now();
let answered: Vec<Duration> = peers
.iter_mut()
.map(|peer| {
assert!(read_reply(peer).is_some());
started.elapsed()
})
.collect();
stopping.store(true, Ordering::SeqCst);
serving.join().unwrap().unwrap();
let gap = answered[4] - answered[3];
assert!(gap < Duration::from_millis(300), "the second batch waited {gap:?}: the loop slept after a busy pass ({answered:?})");
}
// ---- fork_too_large over the real socket ----
fn forks_page(who: &str, cursor: Option<&str>) -> DeskRequest {
let mut value = serde_json::to_value(forks_request(who, &[who])).unwrap();
value["Forks"]["cursor"] = cursor.map_or(serde_json::Value::Null, |text| serde_json::json!(text));
serde_json::from_value(value).unwrap()
}
fn fork_read(who: &str, fork_id: &str) -> DeskRequest {
DeskRequest::Fork { mind: INSTANCE.into(), subject: short(who), deciders: vec![short(who)], fork_id: fork_id.into() }
}
/// The wire spelling of a not-stored refusal's code, and its text.
fn refusal_of(response: &DeskResponse) -> Option<(String, String)> {
let DeskResponse::NotStored(refusal) = response else { return None };
Some((serde_json::to_value(refusal.code).unwrap().as_str().unwrap().to_string(), refusal.error.clone()))
}
/// Three forks, the middle one's record grown over the socket until its card alone is past the page
/// budget. The list names it and its order (`fork_too_large`), the cursor `c<order>` walks on to the
/// older fork and ends at order 0, a READ of it never carries an order or cursor (an entry while it fits the
/// response bound, the fork-only `fork_too_large` past it), and `authority` on a read is still
/// refused.
#[test]
fn a_fork_too_large_to_page_is_named_by_the_list_and_never_by_the_read() {
let _serial = serial();
assert_ne!(my_uid(), 0, "the door refuses uid 0, so this test cannot run as root");
let (root, run) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let hash = seed(root.path());
{
let mut mind = Mind::open(root.path(), &INSTANCE.into()).unwrap();
let documents = ["Q2", "Q3"]
.into_iter()
.map(|label| PipelineDocument::Question(PipelineQuestion {
campaign: "eureka-state".into(),
label: label.into(),
title: Title("Who owns the state?".into()),
question: "Who owns the state?".into(),
options: vec![
QuestionOption { label: "A".into(), text: "the organ".into() },
QuestionOption { label: "B".into(), text: "the client".into() },
],
recommended: "A".into(),
depends: vec![],
raised_in: None,
asked_on: Date("2026-09-16".into()),
}))
.collect();
let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: short("claude"), session: short("session-2"), tool: short("admit"), imported_from: None };
let outcome = mind.admit(PipelineAdmissionBatch { instance: INSTANCE.into(), provenance, documents }, Utc.with_ymd_and_hms(2026, 9, 16, 12, 5, 0).unwrap());
assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}");
}
let (desk, log) = (run.path().join("desk.sock"), run.path().join("daemon.log"));
let _daemon = spawn(root.path(), &desk, my_uid(), &log);
let (older, big, newer) = (FORK, "eureka-state:question:Q2", "eureka-state:question:Q3");
// Notes of about 300 bytes, until the big fork's list page is the refusal.
let note = "a long reason, ".repeat(20);
let mut sent = 0_u32;
let refused = loop {
let listed = ask(&desk, "m-g", &forks_page("x", Some("c2")));
if refusal_of(&listed).is_some() {
break listed;
}
assert!(matches!(listed, DeskResponse::Forks { .. }), "{listed:?}");
sent += 1;
assert!(sent < 400, "the record does not grow");
let mut grow = answer("x", &hash, &format!("00000000-0000-4000-8000-{sent:012}"));
if let DeskRequest::Answer { request, .. } = &mut grow {
request.fork_id = big.into();
request.answer = Some(ForkAnswer { choice: None, note: Some(note.clone()) });
}
assert!(matches!(ask(&desk, "m-w", &grow), DeskResponse::Answered { .. }));
};
// (1) The list names the fork and its order, and nothing else is new in the text.
let (code, text) = refusal_of(&refused).unwrap();
assert_eq!(code, "fork_too_large", "{refused:?}");
assert!(text.contains(big) && text.contains("order 1"), "{text}");
// (2) The cursor c<order> resumes to the older fork, and order 0 is the end. The newest fork pages
// alone ahead of it; the big one is named from the first page too.
let first = ask(&desk, "m-p0", &forks_page("x", None));
let DeskResponse::Forks { list, .. } = first else { panic!("{first:?}") };
assert_eq!((list.forks.iter().map(|e| e.card.fork_id.as_str().to_string()).collect::<Vec<_>>(), list.next_cursor.map(|c| c.order())), (vec![newer.to_string()], Some(2)));
let rest = ask(&desk, "m-p1", &forks_page("x", Some("c1")));
let DeskResponse::Forks { list, .. } = rest else { panic!("{rest:?}") };
assert_eq!((list.forks.iter().map(|e| e.card.fork_id.as_str().to_string()).collect::<Vec<_>>(), list.next_cursor.is_none()), (vec![older.to_string()], true));
assert_eq!(list.forks[0].card.order, 0);
// (3) A read of the same fork: its whole entry while it fits the response bound.
let read = ask(&desk, "m-r", &fork_read("x", big));
let DeskResponse::Fork { entry, .. } = &read else { panic!("a read of the oversize fork is its entry, not a refusal: {read:?}") };
assert_eq!((entry.card.fork_id.as_str(), entry.card.order), (big, 1));
// Past DESK_MAX_RESPONSE_BYTES the read is the fork-only fork_too_large (no order, no cursor).
let mut more = 0_u32;
let too_big = loop {
let read = ask(&desk, "m-r", &fork_read("x", big));
if matches!(read, DeskResponse::NotStored(_)) {
break read;
}
assert!(matches!(read, DeskResponse::Fork { .. }), "{read:?}");
more += 1;
assert!(more < 400, "the record does not grow");
let mut grow = answer("x", &hash, &format!("00000000-0000-4000-8000-{:012}", 1000 + more));
if let DeskRequest::Answer { request, .. } = &mut grow {
request.fork_id = big.into();
request.answer = Some(ForkAnswer { choice: None, note: Some(note.clone()) });
}
assert!(matches!(ask(&desk, "m-w", &grow), DeskResponse::Answered { .. }));
};
let (code, text) = refusal_of(&too_big).unwrap();
assert!(code == "fork_too_large" && text.contains(big) && !text.contains("order"), "{text}");
assert!(refusal_of(&ask(&desk, "m-g", &forks_page("x", Some("c2")))).is_some_and(|(code, _)| code == "fork_too_large"));
// (4) `authority` on a read is still refused whole (the contract's request_invalid is the door's payload-not-a-request).
for (operation, request) in [("forks", forks_request("x", &["x"])), ("fork", fork_read("x", big))] {
let mut value = serde_json::to_value(&request).unwrap();
value[if operation == "forks" { "Forks" } else { "Fork" }]["authority"] = serde_json::json!("Operator");
let message = CultNetMessage::OperationRequest {
message_id: "m-ra".into(),
service_id: huginn_mind::desk_wire::DESK_SERVICE_ID.into(),
operation: operation.into(),
payload_schema: huginn_mind::desk_wire::DESK_REQUEST_SCHEMA.into(),
payload_encoding: "messagepack-base64".into(),
payload: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, rmp_serde::to_vec_named(&value).unwrap()),
source_runtime_id: None,
target_runtime_id: None,
};
let reply = exchange(&desk, &framed(&message)).expect("the door replied");
assert_eq!(decode_desk_response(&reply).unwrap().1.unwrap_err().code, "payload-not-a-request", "{operation}");
}
}
/// Q1 (order 0), Q2, Q3 held; the daemon up. Returns what the tests need.
fn three_forks() -> (tempfile::TempDir, tempfile::TempDir, String, PathBuf, Daemon) {
let (root, run) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let hash = seed(root.path());
{
let mut mind = Mind::open(root.path(), &INSTANCE.into()).unwrap();
let documents = ["Q2", "Q3"]
.into_iter()
.map(|label| PipelineDocument::Question(PipelineQuestion {
campaign: "eureka-state".into(),
label: label.into(),
title: Title("Who owns the state?".into()),
question: "Who owns the state?".into(),
options: vec![
QuestionOption { label: "A".into(), text: "the organ".into() },
QuestionOption { label: "B".into(), text: "the client".into() },
],
recommended: "A".into(),
depends: vec![],
raised_in: None,
asked_on: Date("2026-09-16".into()),
}))
.collect();
let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: short("claude"), session: short("session-2"), tool: short("admit"), imported_from: None };
let outcome = mind.admit(PipelineAdmissionBatch { instance: INSTANCE.into(), provenance, documents }, Utc.with_ymd_and_hms(2026, 9, 16, 12, 5, 0).unwrap());
assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}");
}
let (desk, log) = (run.path().join("desk.sock"), run.path().join("daemon.log"));
let daemon = spawn(root.path(), &desk, my_uid(), &log);
(root, run, hash, desk, daemon)
}
/// Grows `fork`'s record over the socket until the list page at `cursor` is the refusal; returns it.
fn grown_until_refused(desk: &Path, hash: &str, fork: &str, cursor: Option<&str>) -> DeskResponse {
let note = "a long reason, ".repeat(20);
for sent in 1..400_u32 {
let listed = ask(desk, "m-g", &forks_page("x", cursor));
if refusal_of(&listed).is_some() {
return listed;
}
assert!(matches!(listed, DeskResponse::Forks { .. }), "{listed:?}");
let mut grow = answer("x", hash, &format!("00000000-0000-4000-8000-{sent:012}"));
if let DeskRequest::Answer { request, .. } = &mut grow {
request.fork_id = fork.into();
request.answer = Some(ForkAnswer { choice: None, note: Some(note.clone()) });
}
assert!(matches!(ask(desk, "m-w", &grow), DeskResponse::Answered { .. }));
}
panic!("the record does not grow")
}
fn page_ids(response: DeskResponse) -> (Vec<String>, Option<u64>) {
let DeskResponse::Forks { list, .. } = response else { panic!("{response:?}") };
(list.forks.iter().map(|e| e.card.fork_id.as_str().to_string()).collect(), list.next_cursor.map(|c| c.order()))
}
/// The oldest fork (order 0) oversize: the list names it and its order, and says it is the end, not a cursor.
#[test]
fn the_oldest_fork_too_large_says_the_list_ends_and_names_no_cursor() {
let _serial = serial();
let (_root, _run, hash, desk, _daemon) = three_forks();
let refused = grown_until_refused(&desk, &hash, FORK, Some("c1"));
let (code, text) = refusal_of(&refused).unwrap();
assert_eq!(code, "fork_too_large");
assert!(text.contains(FORK) && text.contains("order 0") && !text.contains("c0"), "{text}");
}
/// The newest fork (order 2) oversize: the first page is the refusal, and c2 resumes to the older forks.
#[test]
fn the_newest_fork_too_large_is_named_and_its_cursor_resumes_to_the_older_forks() {
let _serial = serial();
let (_root, _run, hash, desk, _daemon) = three_forks();
let newest = "eureka-state:question:Q3";
let refused = grown_until_refused(&desk, &hash, newest, None);
let (code, text) = refusal_of(&refused).unwrap();
assert_eq!(code, "fork_too_large");
assert!(text.contains(newest) && text.contains("order 2") && text.contains("c2"), "{text}");
let rest = page_ids(ask(&desk, "m-p", &forks_page("x", Some("c2"))));
assert_eq!(rest, (vec!["eureka-state:question:Q2".to_string(), FORK.to_string()], None));
}
/// Grows `fork` over the socket until a READ of it no longer fits the reply cap; returns the raw reply.
fn grown_until_read_overflows(desk: &Path, hash: &str, fork: &str) -> CultNetMessage {
let note = "a long reason, ".repeat(60);
for sent in 1..400_u32 {
let reply = exchange(desk, &desk_bytes("m-read", &fork_read("x", fork))).expect("the door replied");
if !matches!(decode_desk_response(&reply).unwrap().1, Ok(DeskResponse::Fork { .. })) {
return reply;
}
let mut grow = answer("x", hash, &format!("00000000-0000-4000-8000-{sent:012}"));
if let DeskRequest::Answer { request, .. } = &mut grow {
request.fork_id = fork.into();
request.answer = Some(ForkAnswer { choice: None, note: Some(note.clone()) });
}
assert!(matches!(ask(desk, "m-w", &grow), DeskResponse::Answered { .. }));
}
panic!("the record does not grow")
}
/// A READ of a fork whose entry is past the reply cap is `fork_too_large` naming the fork, with no
/// order and no cursor; a fitting fork still reads; the list's refusal is unchanged; the generic
/// response-too-large stays for every other reply.
#[test]
fn a_read_of_an_oversize_fork_is_fork_too_large_naming_only_the_fork() {
let _serial = serial();
let (_root, _run, hash, desk, _daemon) = three_forks();
let big = "eureka-state:question:Q2";
let reply = grown_until_read_overflows(&desk, &hash, big);
let (id, response) = decode_desk_response(&reply).unwrap();
assert_eq!(id, "m-read");
let (code, text) = refusal_of(&response.unwrap()).expect("a not-stored refusal");
assert_eq!(code, "fork_too_large");
assert!(text.contains(big) && !text.contains("order") && !text.contains("cursor") && !text.contains(" c1"), "{text}");
// Fitting forks read, the others untouched.
for fork in [FORK, "eureka-state:question:Q3"] {
assert!(matches!(ask(&desk, "m-ok", &fork_read("x", fork)), DeskResponse::Fork { .. }));
}
// The list still names the fork and its order, and the cursor goes on.
let listed = ask(&desk, "m-l", &forks_page("x", Some("c2")));
let (code, text) = refusal_of(&listed).unwrap();
assert_eq!(code, "fork_too_large");
assert!(text.contains(big) && text.contains("order 1") && text.contains("c1"), "{text}");
}
/// The encoded size of the reply to a read of `fork` sent under `id`, and the reply. The door echoes the id,
/// so its length moves the reply one byte at a time where a record's growth moves it in base64 steps of four.
fn read_size(desk: &Path, fork: &str, id: &str) -> (usize, CultNetMessage) {
let mut stream = UnixStream::connect(desk).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
stream.write_all(&desk_bytes(id, &fork_read("x", fork))).unwrap();
let mut header = [0_u8; 4];
stream.read_exact(&mut header).unwrap();
let mut payload = vec![0_u8; u32::from_be_bytes(header) as usize];
stream.read_exact(&mut payload).unwrap();
(payload.len(), decode_cultnet_message_from_slice(&payload, CultNetWireContract::CultNetSchemaV0).unwrap())
}
/// The reply cap is exact on a READ: a reply of exactly DESK_MAX_RESPONSE_BYTES is the entry, and the same
/// read one byte longer is `fork_too_large` naming the fork. The record is grown to within 250 bytes of the
/// cap, then the echoed message id is padded byte by byte to land on it.
#[test]
fn a_read_reply_of_exactly_the_cap_is_the_entry_and_one_byte_more_is_fork_too_large() {
let _serial = serial();
let cap = huginn_mind::desk_wire::DESK_MAX_RESPONSE_BYTES;
let (_root, _run, hash, desk, _daemon) = three_forks();
let big = "eureka-state:question:Q2";
let mut sent = 0_u32;
loop {
let (size, reply) = read_size(&desk, big, "m-size");
assert!(matches!(decode_desk_response(&reply).unwrap().1, Ok(DeskResponse::Fork { .. })), "grew past the cap at {size}");
let remaining = cap - size;
if remaining <= 250 {
break;
}
// An answer costs about 513 plus 4/3 of its note (base64); aim the last one at 170 bytes short of the cap.
let note = if remaining > 5000 { 2000 } else { (remaining - 170 - 513) * 3 / 4 };
sent += 1;
let mut request = answer("x", &hash, &format!("00000000-0000-4000-8000-{sent:012}"));
if let DeskRequest::Answer { request, .. } = &mut request {
request.fork_id = big.into();
request.answer = Some(ForkAnswer { choice: None, note: Some("a".repeat(note)) });
}
assert!(matches!(ask(&desk, "m-w", &request), DeskResponse::Answered { .. }));
}
let sizes: Vec<(usize, String)> = (0..250).map(|pad| format!("m-size{}", "x".repeat(pad))).map(|id| (read_size(&desk, big, &id).0, id)).collect();
let (at_cap, id) = sizes.iter().find(|(size, _)| *size == cap).expect("a padded id lands exactly on the cap").clone();
let (_, reply) = read_size(&desk, big, &id);
let response = decode_desk_response(&reply).unwrap().1;
assert!(matches!(response, Ok(DeskResponse::Fork { .. })), "{at_cap} bytes: {response:?}");
let (over, reply) = read_size(&desk, big, &format!("{id}x"));
let (_, response) = decode_desk_response(&reply).unwrap();
let (code, text) = refusal_of(&response.unwrap()).expect("a not-stored refusal");
assert!(code == "fork_too_large" && text.contains(big) && !text.contains("cursor") && !text.contains("order"), "{text}");
assert!(over < cap, "the refusal itself is small: {over}");
}
/// A stale answer is STORED as a refusal (`stale_fork`), never counted, and its replay returns the same
/// stored refusal from the door even once the fork is too large to read: nothing new is stored, and the
/// reply is neither `fork_too_large` nor `ResponseTooLarge` (a stored record carries no card, so it is small).
#[test]
fn a_stale_answer_is_stored_refused_and_its_replay_survives_the_fork_growing_too_large() {
let _serial = serial();
let (_root, _run, hash, desk, daemon) = three_forks();
let fork = "eureka-state:question:Q2";
let mut stale = answer("x", &"0".repeat(64), "00000000-0000-4000-8000-0000ffff0001");
if let DeskRequest::Answer { request, .. } = &mut stale {
request.fork_id = fork.into();
}
let before = daemon.documents();
let DeskResponse::Answered { answer: first, counted_answer_id, .. } = ask(&desk, "m-s1", &stale) else { panic!("a stale answer is stored") };
assert_eq!((first.code, first.outcome.as_str(), counted_answer_id), (Some(ForkAnswerCode::StaleFork), "Refused", None));
let stored = daemon.documents();
assert_eq!(stored, before + 1, "the refusal is a stored record");
let DeskResponse::Fork { entry, .. } = ask(&desk, "m-s2", &fork_read("x", fork)) else { panic!("a read") };
assert!(entry.record.answers.iter().any(|a| a.answer_id == first.answer_id && a.code == Some(ForkAnswerCode::StaleFork)));
assert_eq!(entry.record.counted_answer_id, None);
// The fork grows until it cannot be read.
let reply = grown_until_read_overflows(&desk, &hash, fork);
let (_, response) = decode_desk_response(&reply).unwrap();
assert_eq!(refusal_of(&response.unwrap()).unwrap().0, "fork_too_large");
let grown = daemon.documents();
// The identical request replays the stored refusal: same answer id, nothing stored.
let DeskResponse::Answered { answer: replay, counted_answer_id, .. } = ask(&desk, "m-s1", &stale) else { panic!("the replay is the stored answer") };
assert_eq!((replay.answer_id.clone(), replay.code, replay.outcome.as_str()), (first.answer_id.clone(), Some(ForkAnswerCode::StaleFork), "Refused"));
assert_ne!(counted_answer_id.as_deref(), Some(replay.answer_id.as_str()), "the refusal is never the counted answer");
assert_eq!(daemon.documents(), grown, "the replay stored nothing");
}

View file

@ -2345,6 +2345,22 @@ mod tests {
committed(admit(&mut mind, vec![D::Ruling(quoted)]));
}
#[test]
fn a_steward_ruling_takes_no_operator_quote() {
let mut mind = seeded();
let mut peer = ruling("R1");
peer.authority = RulingAuthority::Steward {
steward: "@heimdall:chat".into(),
repo: eureka_pipeline::OrgRepo("GameCult/Bifrost".into()),
words: "refuse leading zeros".into(),
event: None,
};
peer.operator_quote = Some("all recommendations, go ahead".into());
assert_eq!(refusal(admit(&mut mind, vec![D::Ruling(peer.clone())])), MindRefusal::QuoteWithoutOperator { ruling: id("ruling", "R1") });
peer.operator_quote = None;
committed(admit(&mut mind, vec![D::Ruling(peer)]));
}
#[test]
fn a_mind_ruling_without_a_quote_is_admitted() {
let mut mind = seeded();

View file

@ -7,8 +7,7 @@
//! request id). It builds one `desk_answer` and commits it through
//! `admit_steps` with `Door::Desk`, the same commit primitive every other write
//! uses; no other door may write the kind. `answer_desk` and `DeskStamp` are
//! crate-private: only code inside this crate can stamp, so the verified door
//! that will call them (follow-up desk-answer-door) widens them in its own cut.
//! crate-private: only code inside this crate can stamp.
//!
//! The decider list is an input (`DeskStamp.deciders`, from the door); the Mind
//! never reads one. Whether an answerer decides is membership in that list and
@ -125,7 +124,7 @@ pub fn options_hash(question: &PipelineQuestion) -> Sha256Hex {
}
/// Every desk answer of one fork, with its key.
fn answers_of<'a>(docs: &'a Docs<'_>, fork: &'a str) -> impl Iterator<Item = (&'a str, &'a PipelineDeskAnswer)> + 'a {
pub(crate) fn answers_of<'a>(docs: &'a Docs<'_>, fork: &'a str) -> impl Iterator<Item = (&'a str, &'a PipelineDeskAnswer)> + 'a {
docs.of_kind(PipelineKind::DeskAnswer).filter_map(move |(key, document)| match document {
PipelineDocument::DeskAnswer(answer) if answer.fork.0 == fork => Some((key, answer)),
_ => None,

View file

@ -1,8 +1,10 @@
//! The fork desk's answer wire, Huginn's half: Libby's answer-side documents
//! (CultLib `contracts/cultnet/governance/`, the contract file and its vectors at 6231ecad)
//! (CultLib `contracts/cultnet/governance/`, the contract file and its vectors at e57fbaee)
//! as Rust types, the one conversion between her `n<seq>` answer ids and the
//! record keys, the projection of a stored answer, the refusal mapping and the
//! showability predicate, and `Mind::desk_answer`, the one call that runs them in order.
//! The read side (card, record, entry and list, `Mind::desk_forks` and `Mind::desk_fork`)
//! is the child module `reads`.
//!
//! `Mind::answer_desk` stays the only owner of an answer's outcome, sequence,
//! request id lookup, replay and conflict; this module converts, projects and
@ -10,12 +12,15 @@
//! stays the only owner of "counts": the response builder calls it at the head
//! the response is read at, and nothing here stores it. Huginn writes
//! `gamecult.desk_answer.v0` and its own refusals; the stale-fork display
//! and the answer result are Bifrost's to attach and write. The envelope, the published
//! schemas, the reads and the socket are other cuts.
//! and the answer result are Bifrost's to attach and write. The request type, its
//! constants and the published schemas are here; the socket is the daemon's.
mod reads;
pub use reads::{CardSchema, CardStatus, EntrySchema, ForkCard, ForkCursor, ForkEntry, ForkLimit, ForkList, ForkOption, ForkRecord, ForkRef, ListSchema, ListStatus, RecordSchema};
use chrono::{DateTime, Utc};
use eureka_pipeline::{
DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, RequestId, Sha256Hex, Short, Words,
DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, RequestId, Sha256Hex, Short, Slug, Words,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@ -55,7 +60,7 @@ fn required<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(deserializer:
}
/// `gamecult.fork.answer_request.v0`: closed, her field order. The wire types hold text; the
/// shape step of `Mind::answer_desk` judges it. The subject and the attribution are the
/// shape step of `Mind::answer_desk` judges it. The subject and the authority are the
/// door's context and are no field here.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
@ -147,13 +152,14 @@ pub struct ForkDeskAnswer {
pub request_id: String,
}
/// The three requests Huginn refuses without storing, by code.
/// The refusals Huginn sends without storing, by code. `ForkTooLarge` is the list's alone.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ForkRefusalCode {
ForkUnknown,
BodyInvalid,
RequestIdConflict,
ForkTooLarge,
}
/// `gamecult.fork.refusal.v0`. `error` is a fixed sentence, never an echo of what was sent;
@ -169,17 +175,173 @@ pub struct ForkRefusal {
/// What the door answers one desk request with. `Answered` is a stored answer, refused ones
/// included (a stale fork, a non-decider and the rest are stored), fresh or replayed alike.
/// `NotStored` is a request that stored nothing. `Refused` is any other Mind refusal; an
/// `Unavailable` is Bifrost's to turn into its 503, and Huginn writes no document for it.
/// `Forks` and `Fork` are the reads: one page of entries, or one entry, with the head they were
/// all read at. `NotStored` is a request that stored nothing (an unknown fork included).
/// `Refused` is any other Mind refusal; an `Unavailable` is Bifrost's to turn into its 503, and
/// Huginn writes no document for it.
///
/// The trust boundary of the reads (ruling `desk-read-visibility-whole-record`): `Forks` and `Fork`
/// carry the WHOLE record, every answer's note included, to whatever the peer-checked socket
/// carries. Huginn strips nothing and gates nothing; Bifrost applies visibility at its door (a
/// stripped read serves `note: null`, CultLib `fork-card.md`). Huginn must therefore be reachable
/// only by Bifrost's peer-checked socket: the peer-uid check IS the whole authentication, and a
/// second reader of that socket is a note leak.
// One value per call, built and encoded once; a box would only add a deref to every reader.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum DeskResponse {
Answered { answer: ForkDeskAnswer, counted_answer_id: Option<String>, as_of: u64 },
Forks { list: ForkList, as_of: u64 },
Fork { entry: ForkEntry, as_of: u64 },
NotStored(ForkRefusal),
Refused(MindRefusal),
}
impl DeskResponse {
/// The envelope's status: `rejected` exactly when nothing was stored or the Mind refused.
pub fn status(&self) -> &'static str {
match self {
Self::Answered { .. } | Self::Forks { .. } | Self::Fork { .. } => "accepted",
Self::NotStored(_) | Self::Refused(_) => "rejected",
}
}
}
/// The envelope's `service_id` for the desk door.
pub const DESK_SERVICE_ID: &str = "huginn.desk";
pub const DESK_REQUEST_SCHEMA: &str = "huginn.desk_request.v1";
pub const DESK_RESPONSE_SCHEMA: &str = "huginn.desk_response.v1";
/// The published schemas, derived from `DeskRequest` and `DeskResponse` and pinned byte for byte
/// by `published_desk_schemas_match_derivation`.
pub const DESK_REQUEST_SCHEMA_JSON: &str = include_str!("../../../schemas/cultnet/huginn.desk_request.v1.schema.json");
pub const DESK_RESPONSE_SCHEMA_JSON: &str = include_str!("../../../schemas/cultnet/huginn.desk_response.v1.schema.json");
/// The frame bounds the door enforces, on the encoded CultNet message.
pub const DESK_MAX_REQUEST_BYTES: usize = 65536;
pub const DESK_MAX_RESPONSE_BYTES: usize = 65536;
/// What a page of forks leaves for the envelope around it: the echoed message id (at most 256
/// bytes), the operation, the runtime id and the frame's own keys. `Mind::desk_forks` fills a page
/// to `DESK_PAGE_BUDGET`, the response bound less this, measured on the bare message.
pub const DESK_ENVELOPE_MARGIN: usize = 1024;
pub const DESK_PAGE_BUDGET: usize = DESK_MAX_RESPONSE_BYTES - DESK_ENVELOPE_MARGIN;
/// Most deciders one call carries (CultLib `card.deciders` `maxItems`).
pub const DESK_DECIDERS_MAX: usize = 64;
/// A decider list, read whole or not at all: one to `DESK_DECIDERS_MAX` subjects, none twice. A
/// bad list is the decoder's refusal, which names the field and never quotes a subject.
fn deciders<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<Short>, D::Error> {
struct List;
impl<'de> serde::de::Visitor<'de> for List {
type Value = Vec<Short>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a list of distinct deciders")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<Short>, A::Error> {
let mut list = Vec::new();
while let Some(subject) = seq.next_element::<Short>()? {
if list.len() == DESK_DECIDERS_MAX {
return Err(serde::de::Error::custom("too many deciders"));
}
if list.contains(&subject) {
return Err(serde::de::Error::custom("a decider is listed twice"));
}
list.push(subject);
}
if list.is_empty() {
return Err(serde::de::Error::custom("no deciders"));
}
Ok(list)
}
}
deserializer.deserialize_seq(List)
}
fn deciders_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "array",
"items": generator.subschema_for::<Short>(),
"minItems": 1,
"maxItems": DESK_DECIDERS_MAX,
"uniqueItems": true
})
}
/// Every request the desk door answers. The fields are the wire names: the `mind` addressed
/// (the persona and pipeline requests say `instance`; the sets are separate), the verified
/// `subject` and `authority` Bifrost vouches for (door context, never body fields), and the
/// `deciders` of this call (1 to 64, distinct; the Mind never stores them). Closed: a key outside
/// the set refuses the whole payload.
///
/// The reads, `Forks` and `Fork`, carry NO `authority` (CultLib `list_request` and
/// `read_request` are closed and have none; one that names it is refused whole). Their `subject`
/// and `deciders` gate nothing: `deciders` only fills the card's `deciders` and the showability
/// gate, `subject` is read by no read. `Forks` takes the contract's `list_request` fields flat
/// beside them (`status`, `cursor`, `limit`), each key written, `null` where absent. Huginn
/// answers every read with the whole record and Bifrost applies visibility; see `DeskResponse`
/// for the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the
/// reads spec.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub enum DeskRequest {
Answer {
mind: Slug,
subject: Short,
authority: DeskAuthority,
#[serde(deserialize_with = "deciders")]
#[schemars(schema_with = "deciders_schema")]
deciders: Vec<Short>,
request: ForkAnswerRequest,
},
Forks {
mind: Slug,
subject: Short,
#[serde(deserialize_with = "deciders")]
#[schemars(schema_with = "deciders_schema")]
deciders: Vec<Short>,
#[serde(deserialize_with = "required")]
#[schemars(required)]
status: Option<ListStatus>,
#[serde(deserialize_with = "required")]
#[schemars(required)]
cursor: Option<ForkCursor>,
limit: ForkLimit,
},
Fork {
mind: Slug,
subject: Short,
#[serde(deserialize_with = "deciders")]
#[schemars(schema_with = "deciders_schema")]
deciders: Vec<Short>,
fork_id: String,
},
}
impl DeskRequest {
/// The envelope's `operation`.
pub fn operation(&self) -> &'static str {
match self {
Self::Answer { .. } => "answer",
Self::Forks { .. } => "forks",
Self::Fork { .. } => "fork",
}
}
/// The mind the request addresses, for `Mind::require_instance`.
pub fn mind(&self) -> &Slug {
match self {
Self::Answer { mind, .. } | Self::Forks { mind, .. } | Self::Fork { mind, .. } => mind,
}
}
pub fn deciders(&self) -> &[Short] {
match self {
Self::Answer { deciders, .. } | Self::Forks { deciders, .. } | Self::Fork { deciders, .. } => deciders,
}
}
}
// ---- the one conversion between her answer ids and the record keys ----
/// The key prefix of one fork's answers. The only place a desk answer key is spelled.
@ -221,6 +383,19 @@ fn fork_unknown() -> ForkRefusal {
fork_refusal(ForkRefusalCode::ForkUnknown, "fork_unknown: no such fork".into())
}
/// A list page whose first entry alone is past the page budget: names that fork and its `order`. A
/// client continues with the cursor `c<order>`; at order 0 there is no such cursor and the list ends.
fn fork_too_large(fork: &str, order: u64) -> ForkRefusal {
let onward = if order == 0 { "this is the end of the list".to_string() } else { format!("continue with cursor c{order}") };
fork_refusal(ForkRefusalCode::ForkTooLarge, format!("fork_too_large: fork {fork} at order {order} is past the page budget by itself; {onward}"))
}
/// A READ of one fork whose entry is past the reply cap: names the fork, nothing else (a read has no
/// order to continue from and no cursor). The door raises it where it measures the reply.
pub fn fork_too_large_read(fork: &str) -> ForkRefusal {
fork_refusal(ForkRefusalCode::ForkTooLarge, format!("fork_too_large: fork {fork} is past the response bound by itself"))
}
fn body_invalid(field: &str) -> ForkRefusal {
fork_refusal(ForkRefusalCode::BodyInvalid, format!("body_invalid: the body does not satisfy gamecult.fork.answer_request.v0 ({field})"))
}
@ -294,10 +469,15 @@ fn project(key: &str, stored: &PipelineDeskAnswer) -> Result<ForkDeskAnswer, Min
/// question id, or whose id is over the 200-byte bound, names no fork), a body the shape step
/// refused and a reused request id store nothing and have codes of their own; anything else,
/// `Unavailable` included, passes through unchanged.
/// The Mind's `DeskRequestInvalid` fields that come from the fork id alone.
const FORK_FIELDS: [&str; 4] = ["fork", "desk_answer.fork", "desk_answer.key", "desk_answer.campaign"];
fn door_refusal(refused: MindRefusal) -> DeskResponse {
match refused {
MindRefusal::MissingReference { .. } | MindRefusal::CampaignMoved { .. } => DeskResponse::NotStored(fork_unknown()),
MindRefusal::DeskRequestInvalid { field } if field == "fork" || field == "desk_answer.fork" => DeskResponse::NotStored(fork_unknown()),
// Any bad fork id is `fork_unknown`. The Mind derives these four fields from the fork id alone
// (the id's shape, its campaign root, the record key built from it), so each one is the fork.
MindRefusal::DeskRequestInvalid { field } if FORK_FIELDS.contains(&field.as_str()) => DeskResponse::NotStored(fork_unknown()),
MindRefusal::DeskRequestInvalid { field } => DeskResponse::NotStored(body_invalid(&field)),
MindRefusal::DeskRequestIdConflict { .. } => {
DeskResponse::NotStored(fork_refusal(ForkRefusalCode::RequestIdConflict, "request_id_conflict: this request_id was used for a different body".into()))
@ -317,21 +497,20 @@ fn showable(question: &PipelineQuestion, deciders: &[Short]) -> bool {
impl<S: MindStore> Mind<S> {
/// One desk answer from the door: the wire request converted, the question gated, the answer
/// decided and stored (or replayed) by `answer_desk`, and the stored answer projected with the
/// derived count at the head it was read at. The flag is `true` when nothing was committed (a
/// replay); it is for the door and is never encoded. `subject` and `attribution` are the verified
/// decided and stored (or committed) by `answer_desk`, and the stored answer projected with the
/// derived count at the head it was read at. `subject` and `authority` are the verified
/// caller's, `deciders` the door's list.
pub fn desk_answer(
&mut self,
deciders: &[Short],
subject: Short,
attribution: DeskAuthority,
authority: DeskAuthority,
request: ForkAnswerRequest,
now: DateTime<Utc>,
) -> (DeskResponse, bool) {
match self.desk_response(deciders, subject, attribution, &request, now) {
) -> DeskResponse {
match self.desk_response(deciders, subject, authority, &request, now) {
Ok(done) => done,
Err(refused) => (door_refusal(refused), false),
Err(refused) => door_refusal(refused),
}
}
@ -339,13 +518,13 @@ impl<S: MindStore> Mind<S> {
&mut self,
deciders: &[Short],
subject: Short,
attribution: DeskAuthority,
authority: DeskAuthority,
request: &ForkAnswerRequest,
now: DateTime<Utc>,
) -> Result<(DeskResponse, bool), MindRefusal> {
) -> Result<DeskResponse, MindRefusal> {
let sent = match request_of(request) {
Ok(sent) => sent,
Err(refused) => return Ok((DeskResponse::NotStored(refused), false)),
Err(refused) => return Ok(DeskResponse::NotStored(refused)),
};
let image = self.image()?;
let docs = Docs::at(image, image.head);
@ -354,12 +533,11 @@ impl<S: MindStore> Mind<S> {
let unshowable = bound_answer(&docs, &subject, &sent.request_id).is_none()
&& matches!(docs.find(PipelineKind::Question, &sent.fork.0), Some(PipelineDocument::Question(question)) if !showable(question, deciders));
if unshowable {
return Ok((DeskResponse::NotStored(fork_unknown()), false));
return Ok(DeskResponse::NotStored(fork_unknown()));
}
let stamp = DeskStamp { answerer: subject, authority: attribution, deciders: deciders.to_vec() };
let (key, replayed) = match self.answer_desk(stamp, sent, now) {
DeskAnswerOutcome::Stored { answer, .. } => (answer.0, false),
DeskAnswerOutcome::Replayed { answer, .. } => (answer.0, true),
let stamp = DeskStamp { answerer: subject, authority, deciders: deciders.to_vec() };
let key = match self.answer_desk(stamp, sent, now) {
DeskAnswerOutcome::Stored { answer, .. } | DeskAnswerOutcome::Replayed { answer, .. } => answer.0,
DeskAnswerOutcome::Refused(refused) => return Err(refused),
};
let image = self.image()?;
@ -367,7 +545,7 @@ impl<S: MindStore> Mind<S> {
let Some(PipelineDocument::DeskAnswer(stored)) = docs.find(PipelineKind::DeskAnswer, &key) else { return Err(unreadable_key()) };
let fork = stored.fork.0.as_str();
let counted_answer_id = counted_answer(&docs, fork).map(|counted| answer_seq(fork, &counted).map(n_text).ok_or_else(unreadable_key)).transpose()?;
Ok((DeskResponse::Answered { answer: project(&key, stored)?, counted_answer_id, as_of: image.head }, replayed))
Ok((DeskResponse::Answered { answer: project(&key, stored)?, counted_answer_id, as_of: image.head }))
}
}
@ -455,8 +633,12 @@ mod tests {
list.iter().map(|who| s(who)).collect()
}
/// The response and whether the call committed anything: a replay, a refused body and a gate
/// refusal store nothing, so the head does not move.
fn ask_as(mind: &mut Mind<MemoryStore>, who: &str, list: &[&str], request: ForkAnswerRequest) -> (DeskResponse, bool) {
mind.desk_answer(&deciders(list), s(who), DeskAuthority::Member, request, now())
let before = head(mind);
let response = mind.desk_answer(&deciders(list), s(who), DeskAuthority::Member, request, now());
(response, head(mind) != before)
}
fn ask(mind: &mut Mind<MemoryStore>, who: &str, request: ForkAnswerRequest) -> (DeskResponse, bool) {
@ -717,9 +899,9 @@ mod tests {
assert_eq!((answer.outcome.as_str(), answer.retracts.as_deref(), counted.as_deref(), as_of), ("Retraction", Some("n1"), Some("n2"), head(&mind)));
// The first request replayed: its stored word is still Counted, the count is read now.
let before = head(&mind);
let (response, replayed) = ask(&mut mind, "x", first);
let (response, committed) = ask(&mut mind, "x", first);
let (answer, counted, as_of) = answered(response);
assert!(replayed);
assert!(!committed);
assert_eq!((answer.outcome.as_str(), answer.answer_id.as_str(), counted.as_deref(), as_of), ("Counted", "n1", Some("n2"), before));
assert_eq!(head(&mind), before);
// The second request replayed now counts, and its stored word is still Revision.
@ -855,14 +1037,14 @@ mod tests {
];
for (form, request) in forms.into_iter().enumerate() {
let stored = stored_count(&mind, FORK);
let (response, replayed) = ask(&mut mind, "x", request.clone());
assert!(!replayed);
let (response, committed) = ask(&mut mind, "x", request.clone());
assert!(committed);
let (answer, _, _) = answered(response.clone());
assert_eq!((answer.outcome.as_str(), answer.code), ("Refused", Some(ForkAnswerCode::BadRetraction)), "form {form}");
assert_eq!(stored_count(&mind, FORK), stored + 1, "form {form}");
// The refusal is stored and bound: the same bytes replay, with no write.
let before = head(&mind);
assert_eq!(ask(&mut mind, "x", request), (response, true), "form {form}");
assert_eq!(ask(&mut mind, "x", request), (response, false), "form {form}");
assert_eq!(head(&mind), before);
}
// (i) and (iii) with equal answer and target differ only in `retracted`: one id, two bodies.
@ -924,7 +1106,7 @@ mod tests {
}
assert_eq!(head(&mind), before);
// The mapping is by exact field: a field that merely ends in "fork" is a bad body.
for field in ["desk_answer.fork", "fork"] {
for field in ["desk_answer.fork", "fork", "desk_answer.key", "desk_answer.campaign"] {
assert_eq!(not_stored(door_refusal(MindRefusal::DeskRequestInvalid { field: field.into() })).code, ForkRefusalCode::ForkUnknown, "{field}");
}
for field in ["notfork", "desk_answer.forks", "desk_answer.fork.x", "request"] {
@ -932,6 +1114,34 @@ mod tests {
}
}
/// Any bad fork id is `fork_unknown`, whichever field the Mind faults: the id's shape, its
/// campaign root or the record key built from it. A bad field that is not the fork stays `body_invalid`.
#[test]
fn every_bad_fork_id_is_unknown_and_other_bad_fields_are_not() {
let mut mind = desk();
let hash = hash_of(&mind, FORK);
let before = head(&mind);
let forks = [
"", // fork
"eureka-state:epic:Q1", // fork: not a question id
"eureka-state:question:Q 1", // desk_answer.fork
":question:Q1", // desk_answer.campaign
"Bad Root:question:Q1", // desk_answer.campaign
"a:question:Q1.2", // desk_answer.key
"eureka-state:question:Q99", // a well-formed id nothing holds
];
for fork in forks {
let refusal = not_stored(ask(&mut mind, "x", wire(fork, &hash)).0);
assert_eq!(refusal, fork_unknown(), "{fork:?}");
assert_eq!(head(&mind), before, "{fork:?}");
}
let bad_choice = ForkAnswerRequest { answer: Some(ForkAnswer { choice: Some(String::new()), note: None }), ..wire(FORK, &hash) };
let refusal = not_stored(ask(&mut mind, "x", bad_choice).0);
assert_eq!(refusal.code, ForkRefusalCode::BodyInvalid);
assert!(refusal.error.contains("desk_answer.choice"), "{refusal:?}");
assert_eq!(head(&mind), before);
}
#[test]
fn wire_codes_are_snake_case() {
let table = [
@ -960,14 +1170,14 @@ mod tests {
fn a_stale_answer_carries_no_card() {
let mut mind = desk();
let stale = wire(FORK, &"e".repeat(64));
let (fresh, replayed) = ask(&mut mind, "x", stale.clone());
assert!(!replayed);
let (fresh, committed) = ask(&mut mind, "x", stale.clone());
assert!(committed);
let (answer, _, _) = answered(fresh.clone());
assert_eq!((answer.outcome.as_str(), answer.code), ("Refused", Some(ForkAnswerCode::StaleFork)));
// After the fork closed, the retry still answers the stored refusal, with nothing added.
close(&mut mind, FORK, "RQ1", "A");
let (again, replayed) = ask(&mut mind, "x", stale);
assert!(replayed);
let (again, committed) = ask(&mut mind, "x", stale);
assert!(!committed);
let (again_answer, _, _) = answered(again.clone());
assert_eq!(again_answer, answer);
for response in [&fresh, &again] {
@ -982,7 +1192,7 @@ mod tests {
let request = wire(FORK, &hash);
let (fresh, first) = ask(&mut mind, "x", request.clone());
let (replay, second) = ask(&mut mind, "x", request);
assert_eq!((first, second), (false, true));
assert_eq!((first, second), (true, false));
assert_eq!(encode(&fresh), encode(&replay));
assert_eq!(stored_count(&mind, FORK), 1);
}
@ -1031,21 +1241,42 @@ mod tests {
let mut mind = desk();
let hash = hash_of(&mind, FORK);
let request = wire(FORK, &hash);
let (stored, replayed) = ask(&mut mind, "x", request.clone());
assert!(!replayed);
let (stored, committed) = ask(&mut mind, "x", request.clone());
assert!(committed);
let before = head(&mind);
// The list Bifrost passes is empty now; the stored request still gets its stored answer.
let (again, replayed) = ask_as(&mut mind, "x", &[], request.clone());
assert!(replayed);
let (again, committed) = ask_as(&mut mind, "x", &[], request.clone());
assert!(!committed);
assert_eq!(encode(&again), encode(&stored));
assert_eq!(head(&mind), before);
// Another subject with the same bytes has stored nothing: the gate refuses it.
let (other, replayed) = ask_as(&mut mind, "y", &[], request);
assert_eq!((not_stored(other).code, replayed), (ForkRefusalCode::ForkUnknown, false));
let (other, committed) = ask_as(&mut mind, "y", &[], request);
assert_eq!((not_stored(other).code, committed), (ForkRefusalCode::ForkUnknown, false));
// And a request nobody stored is fork_unknown while the list is empty.
let fresh = wire(FORK, &hash);
assert_eq!(not_stored(ask_as(&mut mind, "x", &[], fresh).0).code, ForkRefusalCode::ForkUnknown);
assert_eq!((head(&mind), stored_count(&mind, FORK)), (before, 1));
// A stored refusal and a stored revision replay the same way: a stale hash is stored as a
// refusal, a second answer by the same subject as a revision.
let stale = wire(FORK, "0".repeat(hash.len()).as_str());
let (refused, committed) = ask(&mut mind, "x", stale.clone());
assert!(committed);
assert_eq!(answered(refused.clone()).0.outcome, "Refused");
let revision = wire(FORK, &hash);
let (revised, committed) = ask(&mut mind, "x", revision.clone());
assert!(committed);
assert_eq!(answered(revised.clone()).0.outcome, "Revision");
let before = head(&mind);
for (request, stored) in [(stale, refused), (revision, revised)] {
let (again, committed) = ask_as(&mut mind, "x", &[], request.clone());
assert!(!committed);
// `as_of` is the head the replay was read at, so the stored answer is what is compared.
assert_eq!(encode(&answered(again).0), encode(&answered(stored).0));
assert_eq!(head(&mind), before);
let (other, committed) = ask_as(&mut mind, "w", &[], request);
assert_eq!((not_stored(other).code, committed), (ForkRefusalCode::ForkUnknown, false));
assert_eq!(head(&mind), before);
}
}
#[test]
@ -1107,16 +1338,16 @@ mod tests {
let (first, _) = ask(&mut mind, "x", request.clone());
// V12: the same bytes replay.
let before = head(&mind);
assert_eq!(ask(&mut mind, "x", request.clone()), (first, true));
assert_eq!(ask(&mut mind, "x", request.clone()), (first, false));
assert_eq!(head(&mind), before);
// V13: only answered_at changed is a conflict, and nothing is stored.
let moved = ForkAnswerRequest { answered_at: "2026-11-10T12:00:01Z".into(), ..request.clone() };
assert_eq!(not_stored(ask(&mut mind, "x", moved).0).code, ForkRefusalCode::RequestIdConflict);
assert_eq!((head(&mind), stored_count(&mind, FORK)), (before, 1));
// V14: another subject with identical bytes is its own request: its own answer id, stamped with it.
let (other, replayed) = ask(&mut mind, "y", request.clone());
let (other, committed) = ask(&mut mind, "y", request.clone());
let (other, _, _) = answered(other);
assert!(!replayed);
assert!(committed);
assert_eq!((other.answer_id.as_str(), other.answerer.as_str(), other.outcome.as_str(), other.request_id.as_str()), ("n2", "y", "Revision", request.request_id.as_str()));
let (original, _, _) = answered(ask(&mut mind, "x", request.clone()).0);
assert_eq!((original.answer_id.as_str(), original.answerer.as_str()), ("n1", "x"));
@ -1274,7 +1505,7 @@ mod tests {
let authority = serde_json::from_value(server["authority"].clone()).unwrap();
let at = DateTime::parse_from_rfc3339(server["received_at"].as_str().unwrap()).unwrap().to_utc();
let answerer = s(server["answerer"].as_str().unwrap());
let (mine, _) = mind.desk_answer(&deciders(&list), answerer, authority, request, at);
let mine = mind.desk_answer(&deciders(&list), answerer, authority, request, at);
if code == Some("unavailable") {
continue; // the door went down after the Mind stored: its answer was never seen
}
@ -1326,14 +1557,109 @@ mod tests {
}
#[test]
fn the_attribution_is_stamped_and_never_decides() {
fn the_authority_is_stamped_and_never_decides() {
let mut mind = desk();
let hash = hash_of(&mind, FORK);
// The operator's own account, off the list, is no decider; a member on the list is.
let off = mind.desk_answer(&deciders(&["x"]), s("op"), DeskAuthority::Operator, wire(FORK, &hash), now()).0;
let off = mind.desk_answer(&deciders(&["x"]), s("op"), DeskAuthority::Operator, wire(FORK, &hash), now());
assert_eq!(answered(off).0.code, Some(ForkAnswerCode::NotADecider));
let on = mind.desk_answer(&deciders(&["m"]), s("m"), DeskAuthority::Member, wire(FORK, &hash), now()).0;
let on = mind.desk_answer(&deciders(&["m"]), s("m"), DeskAuthority::Member, wire(FORK, &hash), now());
let (answer, _, _) = answered(on);
assert_eq!((answer.outcome.as_str(), answer.authority, answer.answerer.as_str()), ("Counted", DeskAuthority::Member, "m"));
}
// ---- the desk request on the wire: deciders ride each call, at most 64, never stored ----
use crate::envelope::{OperationFailure, decode_desk_request, encode_desk_request};
fn list_of(n: usize) -> Vec<Short> {
(0..n).map(|at| s(&format!("d{at}"))).collect()
}
/// Each request kind of the desk, carrying `list`.
fn desk_requests(mind: &str, list: &[Short], hash: &str) -> [DeskRequest; 3] {
[
DeskRequest::Answer { mind: slug(mind), subject: s("d0"), authority: DeskAuthority::Member, deciders: list.to_vec(), request: wire(FORK, hash) },
DeskRequest::Forks { mind: slug(mind), subject: s("d0"), deciders: list.to_vec(), status: None, cursor: None, limit: ForkLimit::new(10).unwrap() },
DeskRequest::Fork { mind: slug(mind), subject: s("d0"), deciders: list.to_vec(), fork_id: FORK.into() },
]
}
/// The request as a client sends it and the door reads it.
fn through_the_door(request: &DeskRequest) -> Result<DeskRequest, OperationFailure> {
let message = encode_desk_request("m-1", request, None).unwrap();
decode_desk_request(&message).map(|(_, read)| read)
}
#[test]
fn deciders_are_at_most_sixty_four_per_call_on_answer_forks_and_fork() {
let mut mind = desk();
let hash = hash_of(&mind, FORK);
let names = ["answer", "forks", "fork"];
for (at, sent) in desk_requests(INSTANCE, &list_of(64), &hash).into_iter().enumerate() {
let read = through_the_door(&sent).unwrap_or_else(|failure| panic!("{}: 64 deciders refused: {failure:?}", names[at]));
assert_eq!((read.operation(), read.deciders().len()), (names[at], 64));
assert_eq!(read, sent);
}
// 64 deciders through to the Mind: the subject is listed and counts.
let DeskRequest::Answer { subject, authority, deciders, request, .. } = through_the_door(&desk_requests(INSTANCE, &list_of(64), &hash)[0]).unwrap() else { panic!("not an answer") };
let before = head(&mind);
let response = mind.desk_answer(&deciders, subject, authority, request, now());
assert!(head(&mind) > before);
assert_eq!(answered(response).0.outcome, "Counted");
let (head_before, stored_before) = (head(&mind), stored_count(&mind, FORK));
for (at, sent) in desk_requests(INSTANCE, &list_of(65), &hash).into_iter().enumerate() {
let failure = through_the_door(&sent).unwrap_err();
assert_eq!(failure.code, "payload-not-a-request", "{}", names[at]);
assert!(failure.message.contains("deciders"), "{} names the field: {}", names[at], failure.message);
assert!(!failure.message.contains("d64"), "{} echoes a decider: {}", names[at], failure.message);
}
assert_eq!((head(&mind), stored_count(&mind, FORK)), (head_before, stored_before));
}
#[test]
fn an_empty_or_duplicated_decider_list_is_refused_at_decode_and_one_is_accepted() {
let mut mind = desk();
let hash = hash_of(&mind, FORK);
let before = (head(&mind), stored_count(&mind, FORK));
let mut twice = list_of(3);
twice.push(s("d1"));
for (what, list) in [("empty", vec![]), ("duplicate", twice)] {
for sent in desk_requests(INSTANCE, &list, &hash) {
let failure = through_the_door(&sent).unwrap_err();
assert_eq!(failure.code, "payload-not-a-request", "{what} {}", sent.operation());
assert!(failure.message.contains("deciders") && !failure.message.contains("d1"), "{what} {}: {}", sent.operation(), failure.message);
}
}
// One decider is a list.
for sent in desk_requests(INSTANCE, &list_of(1), &hash) {
assert_eq!(through_the_door(&sent).unwrap(), sent);
}
assert_eq!((head(&mind), stored_count(&mind, FORK)), before);
// The Mind's showability gate is the second line: an empty list handed to it directly
// still refuses the fork and stores nothing.
let refused = not_stored(mind.desk_answer(&[], s("d0"), DeskAuthority::Member, wire(FORK, &hash), now()));
assert_eq!(refused.code, ForkRefusalCode::ForkUnknown);
assert_eq!((head(&mind), stored_count(&mind, FORK)), before);
}
#[test]
fn a_request_for_another_mind_is_refused_and_stores_nothing() {
let mut mind = desk();
let hash = hash_of(&mind, FORK);
let before = (head(&mind), stored_count(&mind, FORK));
for sent in desk_requests(OTHER_INSTANCE, &list_of(4), &hash) {
let read = through_the_door(&sent).unwrap();
assert_eq!(
mind.require_instance(read.mind()),
Err(MindRefusal::ForeignInstance { declared: OTHER_INSTANCE.into(), mind: INSTANCE.into() })
);
}
assert_eq!((head(&mind), stored_count(&mind, FORK)), before);
// The same request to the mind it names is answered.
let DeskRequest::Answer { mind: named, subject, authority, deciders, request } = through_the_door(&desk_requests(INSTANCE, &list_of(4), &hash)[0]).unwrap() else { panic!("not an answer") };
mind.require_instance(&named).unwrap();
assert_eq!(answered(mind.desk_answer(&deciders, subject, authority, request, now())).0.outcome, "Counted");
}
}

File diff suppressed because it is too large Load diff

View file

@ -69,6 +69,11 @@ impl<'i> Docs<'i> {
self.image.id_of(kind, id).filter(|row| self.visible(*row)).map(|row| &self.image.row(row).view.document)
}
/// The ordinal of the receipt that wrote an image document: its admission order.
pub(crate) fn ordinal_of(&self, kind: PipelineKind, id: &str) -> Option<u64> {
self.image.id_of(kind, id).filter(|row| self.visible(*row)).map(|row| self.image.row(row).ordinal)
}
pub(crate) fn find(&self, kind: PipelineKind, id: &str) -> Option<&PipelineDocument> {
self.in_batch(kind, id).or_else(|| self.in_image(kind, id))
}

View file

@ -14,6 +14,7 @@ use anyhow::Result;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use cultnet_rs::CultNetMessage;
use crate::desk_wire::{DESK_REQUEST_SCHEMA, DESK_RESPONSE_SCHEMA, DESK_SERVICE_ID, DeskRequest, DeskResponse};
use crate::wire::{HuginnMindRequest, HuginnMindResponse, MIND_REQUEST_SCHEMA, MIND_RESPONSE_SCHEMA, MIND_SERVICE_ID};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@ -39,17 +40,20 @@ fn encode_payload<T: Serialize>(value: &T) -> Result<String> {
Ok(STANDARD.encode(rmp_serde::to_vec_named(value)?))
}
/// One request, addressed to the mind service and naming its own operation.
pub fn encode_request(
/// One request to `service_id`, naming its own operation, its payload under `payload_schema`.
fn encode_operation_request<T: Serialize>(
message_id: &str,
request: &HuginnMindRequest,
service_id: &str,
payload_schema: &str,
operation: &str,
request: &T,
source_runtime_id: Option<String>,
) -> Result<CultNetMessage> {
Ok(CultNetMessage::OperationRequest {
message_id: message_id.to_string(),
service_id: MIND_SERVICE_ID.to_string(),
operation: request.operation().to_string(),
payload_schema: MIND_REQUEST_SCHEMA.to_string(),
service_id: service_id.to_string(),
operation: operation.to_string(),
payload_schema: payload_schema.to_string(),
payload_encoding: "messagepack-base64".to_string(),
payload: encode_payload(request)?,
source_runtime_id,
@ -59,43 +63,49 @@ pub fn encode_request(
/// The message, or the failure that says why it is not a request of this
/// service. The envelope's `operation` string is checked against the decoded
/// request rather than trusted, so a payload cannot arrive under another
/// operation's name.
pub fn decode_request(message: &CultNetMessage) -> Result<(String, HuginnMindRequest), OperationFailure> {
let CultNetMessage::OperationRequest { message_id, service_id, operation, payload_schema, payload, .. } = message
/// request (`operation_of`) rather than trusted, so a payload cannot arrive
/// under another operation's name.
fn decode_operation_request<T: serde::de::DeserializeOwned>(
message: &CultNetMessage,
service_id: &str,
payload_schema: &str,
operation_of: impl Fn(&T) -> &'static str,
) -> Result<(String, T), OperationFailure> {
let CultNetMessage::OperationRequest { message_id, service_id: sent_service, operation, payload_schema: sent_schema, payload, .. } = message
else {
return Err(OperationFailure::new(
"not-an-operation-request",
"huginn.mind reads cultnet.operation_request.v0",
format!("{service_id} reads cultnet.operation_request.v0"),
));
};
if service_id != MIND_SERVICE_ID {
return Err(OperationFailure::new("wrong-service", format!("the service is not {MIND_SERVICE_ID}")));
if sent_service != service_id {
return Err(OperationFailure::new("wrong-service", format!("the service is not {service_id}")));
}
if payload_schema != MIND_REQUEST_SCHEMA {
if sent_schema != payload_schema {
return Err(OperationFailure::new(
"wrong-payload-schema",
format!("the payload schema is not {MIND_REQUEST_SCHEMA}"),
format!("the payload schema is not {payload_schema}"),
));
}
let bytes = STANDARD
.decode(payload)
.map_err(|_| OperationFailure::new("payload-not-base64", "the payload is not standard base64"))?;
let request = read_request(&bytes)?;
if operation != request.operation() {
let request = read_payload::<T>(&bytes)?;
let expected = operation_of(&request);
if operation != expected {
return Err(OperationFailure::new(
"operation-mismatch",
format!("the envelope's operation is not the payload's {}", request.operation()),
format!("the envelope's operation is not the payload's {expected}"),
));
}
Ok((message_id.clone(), request))
}
/// The payload as a request, or a failure naming the field path and the class
/// The payload as a `T`, or a failure naming the field path and the class
/// of error and nothing the caller sent: a decoder's own text quotes the
/// offending value (an unknown variant, an integer), so no decoder text
/// leaves this function.
fn read_request(bytes: &[u8]) -> Result<HuginnMindRequest, OperationFailure> {
fn read_payload<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, OperationFailure> {
use rmp_serde::decode::Error as Decode;
let mut decoder = rmp_serde::Deserializer::new(bytes);
serde_path_to_error::deserialize(&mut decoder).map_err(|error| {
@ -114,19 +124,22 @@ fn read_request(bytes: &[u8]) -> Result<HuginnMindRequest, OperationFailure> {
})
}
/// One answer, whose status the response derives.
pub fn encode_response(
/// One answer, with the status its caller derived.
fn encode_operation_response<T: Serialize>(
message_id: &str,
service_id: &str,
payload_schema: &str,
operation: &str,
response: &HuginnMindResponse,
status: &str,
response: &T,
source_runtime_id: &str,
) -> Result<CultNetMessage> {
Ok(CultNetMessage::OperationResponse {
message_id: message_id.to_string(),
service_id: MIND_SERVICE_ID.to_string(),
service_id: service_id.to_string(),
operation: operation.to_string(),
status: response.status().to_string(),
payload_schema: MIND_RESPONSE_SCHEMA.to_string(),
status: status.to_string(),
payload_schema: payload_schema.to_string(),
payload_encoding: "messagepack-base64".to_string(),
payload: encode_payload(response)?,
diagnostics: vec![],
@ -136,15 +149,16 @@ pub fn encode_response(
/// The answer to an envelope that never reached a mind. Infallible: the
/// failure is two strings and the daemon must always be able to say no.
pub fn encode_failure(
fn encode_operation_failure(
message_id: &str,
service_id: &str,
operation: &str,
failure: &OperationFailure,
source_runtime_id: &str,
) -> CultNetMessage {
CultNetMessage::OperationResponse {
message_id: message_id.to_string(),
service_id: MIND_SERVICE_ID.to_string(),
service_id: service_id.to_string(),
operation: operation.to_string(),
status: "rejected".to_string(),
payload_schema: FAILURE_SCHEMA.to_string(),
@ -155,21 +169,78 @@ pub fn encode_failure(
}
}
/// The client's side: the correlation key and either the mind's answer or the
/// failure that says the envelope never reached one.
pub fn decode_response(message: &CultNetMessage) -> Result<(String, Result<HuginnMindResponse, OperationFailure>)> {
/// The client's side: the correlation key and either the service's answer or
/// the failure that says the envelope never reached one.
fn decode_operation_response<T: serde::de::DeserializeOwned>(
message: &CultNetMessage,
service_id: &str,
response_schema: &str,
) -> Result<(String, Result<T, OperationFailure>)> {
let CultNetMessage::OperationResponse { message_id, payload_schema, payload, .. } = message else {
anyhow::bail!("huginn.mind answers with cultnet.operation_response.v0");
anyhow::bail!("{service_id} answers with cultnet.operation_response.v0");
};
let bytes = STANDARD.decode(payload)?;
let answer = match payload_schema.as_str() {
MIND_RESPONSE_SCHEMA => Ok(rmp_serde::from_slice::<HuginnMindResponse>(&bytes)?),
FAILURE_SCHEMA => Err(rmp_serde::from_slice::<OperationFailure>(&bytes)?),
other => anyhow::bail!("{other} is neither {MIND_RESPONSE_SCHEMA} nor {FAILURE_SCHEMA}"),
let answer = if payload_schema == response_schema {
Ok(rmp_serde::from_slice::<T>(&bytes)?)
} else if payload_schema == FAILURE_SCHEMA {
Err(rmp_serde::from_slice::<OperationFailure>(&bytes)?)
} else {
anyhow::bail!("{payload_schema} is neither {response_schema} nor {FAILURE_SCHEMA}")
};
Ok((message_id.clone(), answer))
}
/// One request, addressed to the mind service.
pub fn encode_request(
message_id: &str,
request: &HuginnMindRequest,
source_runtime_id: Option<String>,
) -> Result<CultNetMessage> {
encode_operation_request(message_id, MIND_SERVICE_ID, MIND_REQUEST_SCHEMA, request.operation(), request, source_runtime_id)
}
pub fn decode_request(message: &CultNetMessage) -> Result<(String, HuginnMindRequest), OperationFailure> {
decode_operation_request(message, MIND_SERVICE_ID, MIND_REQUEST_SCHEMA, HuginnMindRequest::operation)
}
pub fn encode_response(
message_id: &str,
operation: &str,
response: &HuginnMindResponse,
source_runtime_id: &str,
) -> Result<CultNetMessage> {
encode_operation_response(message_id, MIND_SERVICE_ID, MIND_RESPONSE_SCHEMA, operation, response.status(), response, source_runtime_id)
}
pub fn encode_failure(message_id: &str, operation: &str, failure: &OperationFailure, source_runtime_id: &str) -> CultNetMessage {
encode_operation_failure(message_id, MIND_SERVICE_ID, operation, failure, source_runtime_id)
}
pub fn decode_response(message: &CultNetMessage) -> Result<(String, Result<HuginnMindResponse, OperationFailure>)> {
decode_operation_response(message, MIND_SERVICE_ID, MIND_RESPONSE_SCHEMA)
}
/// One request, addressed to the desk service.
pub fn encode_desk_request(message_id: &str, request: &DeskRequest, source_runtime_id: Option<String>) -> Result<CultNetMessage> {
encode_operation_request(message_id, DESK_SERVICE_ID, DESK_REQUEST_SCHEMA, request.operation(), request, source_runtime_id)
}
pub fn decode_desk_request(message: &CultNetMessage) -> Result<(String, DeskRequest), OperationFailure> {
decode_operation_request(message, DESK_SERVICE_ID, DESK_REQUEST_SCHEMA, DeskRequest::operation)
}
pub fn encode_desk_response(message_id: &str, operation: &str, response: &DeskResponse, source_runtime_id: &str) -> Result<CultNetMessage> {
encode_operation_response(message_id, DESK_SERVICE_ID, DESK_RESPONSE_SCHEMA, operation, response.status(), response, source_runtime_id)
}
pub fn encode_desk_failure(message_id: &str, operation: &str, failure: &OperationFailure, source_runtime_id: &str) -> CultNetMessage {
encode_operation_failure(message_id, DESK_SERVICE_ID, operation, failure, source_runtime_id)
}
pub fn decode_desk_response(message: &CultNetMessage) -> Result<(String, Result<DeskResponse, OperationFailure>)> {
decode_operation_response(message, DESK_SERVICE_ID, DESK_RESPONSE_SCHEMA)
}
#[cfg(test)]
mod tests {
use super::*;
@ -324,4 +395,284 @@ mod tests {
let foreign = CultNetMessage::Error { error: "not for you".into(), code: None, details: None };
assert_eq!(decode_request(&foreign).unwrap_err().code, "not-an-operation-request");
}
// ---- the desk's instantiation of the same cores ----
use crate::desk_wire::{
DESK_MAX_REQUEST_BYTES, DESK_MAX_RESPONSE_BYTES, DESK_REQUEST_SCHEMA, DESK_RESPONSE_SCHEMA, DESK_SERVICE_ID, DeskRequest, DeskResponse,
ForkAnswer, ForkAnswerRequest, ForkDeskAnswer, ForkRefusal, ForkRefusalCode, RefusalSchema,
};
use cultnet_rs::{CultNetWireContract, encode_cultnet_message_to_vec};
use eureka_pipeline::DeskAuthority;
fn desk_answer() -> DeskRequest {
DeskRequest::Answer {
mind: slug(INSTANCE),
subject: crate::fixtures::s("user-7"),
authority: DeskAuthority::Member,
deciders: vec![crate::fixtures::s("user-7")],
request: ForkAnswerRequest {
schema_version: crate::desk_wire::RequestSchema::V0,
fork_id: "eureka-state:question:Q1".into(),
options_hash: "0".repeat(64),
answer: Some(ForkAnswer { choice: Some("A".into()), note: None }),
retracted: false,
retracts: None,
answered_at: "2026-11-10T12:00:00Z".into(),
request_id: "00000000-0000-4000-8000-000000000001".into(),
},
}
}
fn desk_requests() -> [(DeskRequest, &'static str); 3] {
let (mind, subject, deciders) = (slug(INSTANCE), crate::fixtures::s("user-7"), vec![crate::fixtures::s("user-7")]);
[
(desk_answer(), "Answer"),
(DeskRequest::Forks { mind: mind.clone(), subject: subject.clone(), deciders: deciders.clone(), status: Some(crate::desk_wire::ListStatus::Open), cursor: crate::desk_wire::ForkCursor::new(7), limit: crate::desk_wire::ForkLimit::new(50).unwrap() }, "Forks"),
(DeskRequest::Fork { mind, subject, deciders, fork_id: "eureka-state:question:Q1".into() }, "Fork"),
]
}
/// The fields of a desk request ARE Libby's wire names, with no mapping layer between: `mind`,
/// `subject`, `authority`, `deciders`, `request`. The persona and pipeline requests keep
/// `instance`; the sets do not mix. A key outside the set, or one that would make the caller
/// its own authority (`answerer`, `attribution`), refuses the whole payload.
#[test]
fn desk_request_fields_are_the_wire_names_and_the_set_is_closed() {
let keys = |value: &serde_json::Value, variant: &str| {
let mut keys: Vec<String> = value[variant].as_object().unwrap().keys().cloned().collect();
keys.sort();
keys
};
let expected = [
("Answer", vec!["authority", "deciders", "mind", "request", "subject"]),
("Forks", vec!["cursor", "deciders", "limit", "mind", "status", "subject"]),
("Fork", vec!["deciders", "fork_id", "mind", "subject"]),
];
for ((request, variant), (named, names)) in desk_requests().iter().zip(expected) {
assert_eq!(*variant, named);
let value = serde_json::to_value(request).unwrap();
assert_eq!(keys(&value, variant), names, "{variant}");
let message = encode_desk_request("m-1", request, None).unwrap();
assert_eq!(decode_desk_request(&message).unwrap(), ("m-1".into(), request.clone()));
for extra in ["answerer", "attribution", "instance", "mood"] {
let mut bad = value.clone();
bad[*variant].as_object_mut().unwrap().insert(extra.into(), serde_json::json!(CANARY_KEY));
let payload = STANDARD.encode(rmp_serde::to_vec_named(&bad).unwrap());
let message = request_message(DESK_SERVICE_ID, request.operation(), DESK_REQUEST_SCHEMA, payload);
let failure = decode_desk_request(&message).unwrap_err();
assert_eq!(failure.code, "payload-not-a-request", "{variant} with {extra}");
assert!(failure.message.contains(variant), "{variant} with {extra}: {}", failure.message);
assert!(!failure.message.contains(CANARY_KEY), "{}", failure.message);
}
// `mind` is the desk's word: renamed to `instance` the payload is no desk request.
let mut renamed = value.clone();
let moved = renamed[*variant].as_object_mut().unwrap().remove("mind").unwrap();
renamed[*variant].as_object_mut().unwrap().insert("instance".into(), moved);
let payload = STANDARD.encode(rmp_serde::to_vec_named(&renamed).unwrap());
let message = request_message(DESK_SERVICE_ID, request.operation(), DESK_REQUEST_SCHEMA, payload);
assert_eq!(decode_desk_request(&message).unwrap_err().code, "payload-not-a-request");
}
// And the other way: the mind's `instance` named `mind` is no persona request.
let put = HuginnMindRequest::PersonaPut {
instance: slug(INSTANCE),
provenance: provenance(Faculty::Hands),
state: serde_json::json!({}),
expected_updated_at: None,
};
let mut value = serde_json::to_value(&put).unwrap();
let moved = value["PersonaPut"].as_object_mut().unwrap().remove("instance").unwrap();
value["PersonaPut"].as_object_mut().unwrap().insert("mind".into(), moved);
let message = request_message(MIND_SERVICE_ID, "persona_put", MIND_REQUEST_SCHEMA, STANDARD.encode(rmp_serde::to_vec_named(&value).unwrap()));
assert_eq!(decode_request(&message).unwrap_err().code, "payload-not-a-request");
}
const CANARY_KEY: &str = "CANARY-9e41-do-not-echo";
/// Two services share the cores and nothing else: a payload under the other service's schema,
/// service id or operation name never reaches a decoder.
#[test]
fn desk_and_mind_payloads_do_not_cross() {
let whoami = encode_request("m-5", &HuginnMindRequest::Whoami, None).unwrap();
let CultNetMessage::OperationRequest { payload: mind_payload, .. } = &whoami else { panic!("not a request") };
for (request, operation) in desk_requests().iter().map(|(request, _)| (request, request.operation())) {
let CultNetMessage::OperationRequest { payload: desk_payload, .. } = encode_desk_request("m-6", request, None).unwrap() else { panic!("not a request") };
// A desk payload under the mind's schema and a mind payload under the desk's.
let under_mind = request_message(DESK_SERVICE_ID, operation, MIND_REQUEST_SCHEMA, desk_payload.clone());
assert_eq!(decode_desk_request(&under_mind).unwrap_err().code, "wrong-payload-schema");
let under_desk = request_message(MIND_SERVICE_ID, "whoami", DESK_REQUEST_SCHEMA, mind_payload.clone());
assert_eq!(decode_request(&under_desk).unwrap_err().code, "wrong-payload-schema");
// The mind's service id at the desk decoder, and the desk's at the mind's.
let mind_service = request_message(MIND_SERVICE_ID, operation, DESK_REQUEST_SCHEMA, desk_payload.clone());
assert_eq!(decode_desk_request(&mind_service).unwrap_err().code, "wrong-service");
let desk_service = request_message(DESK_SERVICE_ID, "whoami", MIND_REQUEST_SCHEMA, mind_payload.clone());
assert_eq!(decode_request(&desk_service).unwrap_err().code, "wrong-service");
// The right labels around the other service's bytes: not a request.
let wrong_bytes = request_message(DESK_SERVICE_ID, operation, DESK_REQUEST_SCHEMA, mind_payload.clone());
assert_eq!(decode_desk_request(&wrong_bytes).unwrap_err().code, "payload-not-a-request");
// An operation name that is not the payload's.
let other = if operation == "forks" { "fork" } else { "forks" };
let mismatch = request_message(DESK_SERVICE_ID, other, DESK_REQUEST_SCHEMA, desk_payload);
assert_eq!(decode_desk_request(&mismatch).unwrap_err().code, "operation-mismatch");
}
// The three operations name themselves, on the envelope too.
let named: Vec<String> = desk_requests()
.iter()
.map(|(request, _)| match encode_desk_request("m", request, None).unwrap() {
CultNetMessage::OperationRequest { service_id, operation, payload_schema, .. } => {
assert_eq!((service_id.as_str(), payload_schema.as_str()), (DESK_SERVICE_ID, DESK_REQUEST_SCHEMA));
operation
}
_ => panic!("not a request"),
})
.collect();
assert_eq!(named, ["answer", "forks", "fork"]);
}
/// A desk answer is an answer on the desk's response schema; a refusal that stored nothing and
/// any other Mind refusal ride `rejected`; a failure rides the shared failure schema under the
/// desk's service id.
#[test]
fn a_desk_response_round_trips_with_its_status() {
let answered = DeskResponse::Answered {
answer: ForkDeskAnswer {
schema_version: crate::desk_wire::AnswerSchema::V0,
answer_id: "n1".into(),
fork_id: "eureka-state:question:Q1".into(),
outcome: "Counted".into(),
of: None,
retracts: None,
code: None,
options_hash: "0".repeat(64),
choice: Some("A".into()),
note: None,
answered_at: "2026-11-10T12:00:00Z".into(),
received_at: "2026-11-10T12:00:01Z".into(),
claim_in_future: false,
answerer: "user-7".into(),
authority: DeskAuthority::Member,
request_id: "00000000-0000-4000-8000-000000000001".into(),
},
counted_answer_id: Some("n1".into()),
as_of: 9,
};
let not_stored = DeskResponse::NotStored(ForkRefusal { schema_version: RefusalSchema::V0, code: ForkRefusalCode::ForkUnknown, error: "fork_unknown".into() });
for (response, status) in [(answered, "accepted"), (not_stored, "rejected"), (DeskResponse::Refused(refusal()), "rejected")] {
let message = encode_desk_response("m-7", "answer", &response, "huginn-yggdrasil").unwrap();
let CultNetMessage::OperationResponse { service_id, status: sent, payload_schema, .. } = &message else { panic!("not a response") };
assert_eq!((service_id.as_str(), sent.as_str(), payload_schema.as_str()), (DESK_SERVICE_ID, status, DESK_RESPONSE_SCHEMA));
assert_eq!(decode_desk_response(&message).unwrap(), ("m-7".into(), Ok(response)));
}
let failure = OperationFailure::new("wrong-service", "the service is not huginn.desk");
let message = encode_desk_failure("m-8", "forks", &failure, "huginn-yggdrasil");
let CultNetMessage::OperationResponse { service_id, status, payload_schema, .. } = &message else { panic!("not a response") };
assert_eq!((service_id.as_str(), status.as_str(), payload_schema.as_str()), (DESK_SERVICE_ID, "rejected", FAILURE_SCHEMA));
assert_eq!(decode_desk_response(&message).unwrap(), ("m-8".into(), Err(failure)));
// The mind's response schema is not the desk's.
let mind = encode_response("m-9", "whoami", &HuginnMindResponse::Refused(refusal()), "huginn-yggdrasil").unwrap();
assert!(decode_desk_response(&mind).is_err());
}
/// The largest message either side can build fits the frame the door enforces: 64 deciders of
/// 200 bytes and a 4000-byte note in a request, the widest stored answer in a response.
#[test]
fn a_maximal_desk_message_fits_the_frame() {
let long = |n: usize| "x".repeat(n);
let wire = |message: &CultNetMessage| encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0).unwrap().len();
let deciders: Vec<_> = (0..64).map(|at| crate::fixtures::s(&format!("{at:0>200}"))).collect();
let mut sized = vec![];
for (request, _) in desk_requests() {
let widest = match request {
DeskRequest::Answer { mind, request, authority, .. } => DeskRequest::Answer {
mind,
subject: crate::fixtures::s(&long(200)),
authority,
deciders: deciders.clone(),
request: ForkAnswerRequest {
fork_id: long(200),
answer: Some(ForkAnswer { choice: Some(long(200)), note: Some(long(4000)) }),
retracts: Some("n4294967295".into()),
..request
},
},
DeskRequest::Forks { mind, status, cursor, limit, .. } => {
DeskRequest::Forks { mind, subject: crate::fixtures::s(&long(200)), deciders: deciders.clone(), status, cursor, limit }
}
DeskRequest::Fork { mind, .. } => {
DeskRequest::Fork { mind, subject: crate::fixtures::s(&long(200)), deciders: deciders.clone(), fork_id: long(200) }
}
};
let message = encode_desk_request("m-10", &widest, Some(long(200))).unwrap();
assert_eq!(decode_desk_request(&message).unwrap().1, widest);
sized.push(wire(&message));
}
assert!(sized.iter().all(|size| *size <= DESK_MAX_REQUEST_BYTES), "{sized:?} against {DESK_MAX_REQUEST_BYTES}");
assert!(sized[0] > DESK_MAX_REQUEST_BYTES / 4, "the test builds a request that is actually large: {sized:?}");
let answer = ForkDeskAnswer {
schema_version: crate::desk_wire::AnswerSchema::V0,
answer_id: "n4294967295".into(),
fork_id: long(200),
outcome: "Refused".into(),
of: Some("n4294967295".into()),
retracts: Some("n4294967295".into()),
code: None,
options_hash: "f".repeat(64),
choice: Some(long(200)),
note: Some(long(4000)),
answered_at: "2026-11-10T12:00:00.123456789+00:00".into(),
received_at: "2026-11-10T12:00:00.123456789+00:00".into(),
claim_in_future: true,
answerer: long(200),
authority: DeskAuthority::Operator,
request_id: "00000000-0000-4000-8000-000000000001".into(),
};
let response = DeskResponse::Answered { answer, counted_answer_id: Some("n4294967295".into()), as_of: u64::MAX };
let message = encode_desk_response("m-11", "answer", &response, &long(200)).unwrap();
assert!(wire(&message) <= DESK_MAX_RESPONSE_BYTES && wire(&message) > 4000, "{}", wire(&message));
assert_eq!(decode_desk_response(&message).unwrap().1, Ok(response));
}
/// A desk call's refusal rides `huginn.desk_response.v1` whole: code and detail survive the
/// wire, and the published schema declares the variant. One row per refusal a desk call can
/// return, the four the door maps to `NotStored` included, because the response type can
/// carry any of them.
#[test]
fn every_refusal_a_desk_call_can_return_round_trips_losslessly() {
use crate::desk_wire::DESK_RESPONSE_SCHEMA_JSON;
use eureka_pipeline::{PipelineKind, PipelineRefusal};
let refusals = [
MindRefusal::Unavailable { detail: "the store did not answer".into() },
MindRefusal::ForeignInstance { declared: OTHER.into(), mind: INSTANCE.into() },
MindRefusal::MissingIdentity,
MindRefusal::DeskAnswerOutsideDesk { id: "eureka-state:desk_answer:Q1.n1".into() },
MindRefusal::MissingReference { kind: PipelineKind::Question, id: "eureka-state:question:Q9".into() },
MindRefusal::CampaignMoved { campaign: "eureka-state".into(), to: OTHER.into() },
MindRefusal::DeskRequestInvalid { field: "desk_answer.note".into() },
MindRefusal::DeskRequestIdConflict { request_id: "00000000-0000-4000-8000-000000000001".into() },
MindRefusal::Document(PipelineRefusal::FieldBound { field: "note".into(), limit: 4000, actual: 4001 }),
MindRefusal::Document(PipelineRefusal::InvalidFormat { field: "request_id".into(), value: "x".into() }),
MindRefusal::Document(PipelineRefusal::InvalidIdentity { kind: PipelineKind::DeskAnswer, key: "k".into(), expected: "e".into() }),
MindRefusal::Document(PipelineRefusal::DuplicateRepo { field: "repos".into(), repo: "GameCult/Huginn".into() }),
MindRefusal::Document(PipelineRefusal::ForeignStore { r#type: "other".into() }),
];
let schema: serde_json::Value = serde_json::from_str(DESK_RESPONSE_SCHEMA_JSON).unwrap();
assert!(schema["$defs"]["MindRefusal"].is_object() && schema["$defs"]["PipelineRefusal"].is_object());
let declared = serde_json::to_string(&schema).unwrap();
for refusal in refusals {
let response = DeskResponse::Refused(refusal.clone());
let message = encode_desk_response("m-12", "answer", &response, "huginn-yggdrasil").unwrap();
let (_, decoded) = decode_desk_response(&message).unwrap();
assert_eq!(decoded, Ok(DeskResponse::Refused(refusal.clone())), "{refusal:?}");
let value = serde_json::to_value(&refusal).unwrap();
let variant = value.as_str().map(str::to_string).or_else(|| value.as_object().and_then(|object| object.keys().next().cloned())).unwrap();
assert!(declared.contains(&format!("\"{variant}\"")), "the published schema declares {variant}");
// Each detail field is declared too, so a validator can hold the payload to the schema.
if let Some(object) = value.get(&variant).and_then(|inner| inner.as_object()) {
for field in object.keys() {
assert!(declared.contains(&format!("\"{field}\"")), "{variant}.{field} is declared");
}
}
}
}
}

View file

@ -7,7 +7,7 @@
//! how a change to it makes every projection rebuild. No network, no JSON, no
//! store handle: the daemon owns the vector store and the embedder.
use eureka_pipeline::{PipelineDocument, PipelineKind, PipelineRef};
use eureka_pipeline::{PipelineDocument, PipelineKind, PipelineRef, RulingAuthority};
use sha2::{Digest, Sha256};
use crate::mind::Mind;
@ -56,6 +56,9 @@ pub fn index_text(document: &PipelineDocument) -> Option<String> {
lines.push(&ruling.title.0);
lines.push(&ruling.ruling.0);
lines.extend(ruling.operator_quote.iter().map(|quote| quote.0.as_str()));
if let RulingAuthority::Steward { words, .. } = &ruling.authority {
lines.push(&words.0);
}
}
D::CutSpec(spec) => {
lines.push(&spec.title.0);
@ -247,6 +250,15 @@ More documents.");
assert_eq!(INDEX_TEXT_VERSION, 1);
}
/// A Steward ruling is found by the words the peer said as well as by its title and ruling.
#[test]
fn a_steward_rulings_words_are_indexed() {
let mut peer = ruling("R1");
peer.operator_quote = None;
peer.authority = RulingAuthority::Steward { steward: s("@heimdall:chat"), repo: eureka_pipeline::OrgRepo(REPO.into()), words: "refuse leading zeros".into(), event: None };
assert!(index_text(&D::Ruling(peer)).unwrap().lines().any(|line| line == "refuse leading zeros"));
}
fn texts(mind: &Mind<MemoryStore>) -> BTreeMap<String, String> {
mind.index_entries(None).unwrap().into_iter().map(|entry| (entry.id.id.0, entry.text)).collect()
}

View file

@ -55,7 +55,7 @@ mod read_tests;
mod move_tests;
pub use desk::{DeskAnswerOutcome, DeskAnswerRequest, DeskBody, options_hash};
pub use desk_wire::{DeskResponse, ForkAnswer, ForkAnswerCode, ForkAnswerRequest, ForkDeskAnswer, ForkRefusal, ForkRefusalCode};
pub use desk_wire::{DeskRequest, DeskResponse, ForkAnswer, ForkAnswerCode, ForkAnswerRequest, ForkDeskAnswer, ForkRefusal, ForkRefusalCode};
pub use docs::CitationRole;
pub use index::{INDEX_TEXT_MAX_BYTES, INDEX_TEXT_VERSION, IndexEntry, index_text};
pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome};

View file

@ -191,7 +191,7 @@ impl PipelineFacts {
title: value.title.clone(),
answers: value.answers.clone(),
choice: value.choice.clone(),
authority: value.authority,
authority: value.authority.clone(),
ruled_on: value.ruled_on.clone(),
},
D::CutSpec(value) => Self::CutSpec {
@ -1504,6 +1504,25 @@ mod tests {
assert!(matches!(refused(&mind, &misspelt), MindRefusal::SelectionInvalid { value: Some(v), .. } if v == "Minds"));
}
/// A peer's ruling is found by the Mind who decided, by the stewardship she decided under, and by authority.
#[test]
fn a_peers_rulings_are_selected_by_mind_and_by_repo() {
let mut mind = seeded();
let mut peer = ruling("R1");
peer.authority = RulingAuthority::Steward {
steward: "@heimdall:chat".into(),
repo: eureka_pipeline::OrgRepo("GameCult/Bifrost".into()),
words: "refuse leading zeros".into(),
event: None,
};
committed(admit(&mut mind, vec![D::Ruling(peer), D::Ruling(ruling("R2"))]));
let found = |alias: &str, word: &str| mind.query(&with(of_kinds(&[K::Ruling]), any_of(alias, &[word]))).unwrap().matched;
assert_eq!(found("authority", "Steward"), 1);
assert_eq!(found("about", "@heimdall:chat"), 1);
assert_eq!(found("repo", "GameCult/Bifrost"), 1);
assert_eq!(found("repo", "GameCult/CultLib"), 0);
}
#[test]
fn an_unknown_schema_or_malformed_key_is_refused() {
let mind = worked();

View file

@ -13,7 +13,7 @@ use std::collections::BTreeMap;
use cultnet_rs::{Row, RowSet, Selection};
use eureka_pipeline::{
FindingConfidence, FindingOrigin, FindingSeverity, Label, MemoryOrigin, MemorySort, OrgRepo, PipelineDocument, PipelineKind, PipelineRef, ResolutionOutcome,
FindingConfidence, FindingOrigin, FindingSeverity, Label, MemoryOrigin, MemorySort, OrgRepo, PipelineDocument, PipelineKind, PipelineRef, PipelineRuling, ResolutionOutcome,
RulingAuthority, RunOperator, RunTurn, Slug,
};
@ -134,7 +134,7 @@ impl Alias {
match self {
Self::Root | Self::InForce | Self::Faculty => true,
Self::Repo => {
matches!(kind, K::Campaign | K::CutSpec | K::CutReport | K::FollowUp | K::Stewardship | K::HandOff | K::Resolution)
matches!(kind, K::Campaign | K::CutSpec | K::CutReport | K::FollowUp | K::Stewardship | K::HandOff | K::Resolution | K::Ruling)
}
Self::Cut => matches!(kind, K::CutSpec | K::CutReport | K::Verdict | K::Finding | K::Resolution),
Self::Severity | Self::Confidence | Self::Origin => kind == K::Finding,
@ -143,7 +143,8 @@ impl Alias {
Self::Outcome => kind == K::Resolution,
Self::Turn | Self::OperatedBy | Self::Ran => kind == K::Run,
Self::Voted | Self::Urged => kind == K::Pulse,
Self::Sort | Self::About | Self::MemoryOrigin => kind == K::Memory,
Self::Sort | Self::MemoryOrigin => kind == K::Memory,
Self::About => matches!(kind, K::Memory | K::Ruling),
Self::Pass => matches!(kind, K::Research | K::Idea),
}
}
@ -246,6 +247,7 @@ impl SelectionRow {
D::FollowUp(follow_up) => vec![follow_up.repo.identity()],
D::Stewardship(stewardship) => vec![stewardship.repo.identity()],
D::HandOff(hand_off) => vec![hand_off.repo.identity()],
D::Ruling(PipelineRuling { authority: RulingAuthority::Steward { repo, .. }, .. }) => vec![repo.identity()],
D::Target(_) | D::Question(_) | D::Ruling(_) | D::Verdict(_) | D::Finding(_) | D::Instance(_) | D::Resolution(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::Thought(_) | D::Memory(_) | D::Research(_) | D::Idea(_) | D::DeskAnswer(_) => {
Vec::new()
}
@ -267,7 +269,12 @@ impl SelectionRow {
put(Alias::Confidence, vec![format!("{:?}", finding.confidence)]);
put(Alias::Origin, vec![format!("{:?}", finding.origin)]);
}
D::Ruling(ruling) => put(Alias::Authority, vec![format!("{:?}", ruling.authority)]),
D::Ruling(ruling) => {
put(Alias::Authority, vec![ruling.authority.name().to_string()]);
if let RulingAuthority::Steward { steward, .. } = &ruling.authority {
put(Alias::About, vec![steward.0.clone()]);
}
}
D::Verdict(verdict) => {
put(Alias::ClaimOutcome, verdict.claims.iter().map(|claim| format!("{:?}", claim.outcome)).collect())
}
@ -463,7 +470,7 @@ mod tests {
(K::Campaign, &["repo"]),
(K::Target, &[]),
(K::Question, &[]),
(K::Ruling, &["authority"]),
(K::Ruling, &["authority", "repo", "about"]),
(K::CutSpec, &["repo", "cut"]),
(K::CutReport, &["repo", "cut"]),
(K::Verdict, &["cut", "claim_outcome"]),

View file

@ -581,4 +581,27 @@ mod tests {
assert!(request.contains(&format!("\"$ref\": \"{selection}\"")));
assert!(!request.contains("\"cursor\""), "no second copy of the selection's shape");
}
/// The desk's two published files are the derivation of its two types, byte for byte, and
/// the catalog index lists both as wire messages beside the mind's.
#[test]
fn published_desk_schemas_match_derivation() {
use crate::desk_wire::{DESK_REQUEST_SCHEMA, DESK_REQUEST_SCHEMA_JSON, DESK_RESPONSE_SCHEMA, DESK_RESPONSE_SCHEMA_JSON, DeskRequest, DeskResponse};
let request = serde_json::to_string_pretty(&schemars::schema_for!(DeskRequest)).unwrap();
let response = serde_json::to_string_pretty(&schemars::schema_for!(DeskResponse)).unwrap();
assert_eq!(DESK_REQUEST_SCHEMA_JSON.replace("\r\n", "\n"), request);
assert_eq!(DESK_RESPONSE_SCHEMA_JSON.replace("\r\n", "\n"), response);
// The deciders bound is in the published schema, not only in the decoder.
assert!(request.contains("\"maxItems\": 64") && request.contains("\"uniqueItems\": true") && request.contains("\"minItems\": 1"), "deciders bound");
let index: serde_json::Value = serde_json::from_str(include_str!("../../../schemas/cultnet/index.json")).unwrap();
for (id, title) in [(DESK_REQUEST_SCHEMA, "Huginn desk request"), (DESK_RESPONSE_SCHEMA, "Huginn desk response")] {
let rows: Vec<_> = index["schemas"].as_array().unwrap().iter().filter(|row| row["schemaId"] == id).collect();
assert_eq!(rows.len(), 1, "{id}");
let row = rows[0];
assert_eq!(
(row["kind"].as_str(), row["schemaVersion"].as_str(), row["title"].as_str(), row["path"].as_str(), row["wireContracts"][0].as_str()),
(Some("wire_message"), Some(id), Some(title), Some(format!("{id}.schema.json").as_str()), Some("cultnet.schema.v0"))
);
}
}
}

File diff suppressed because one or more lines are too long

View file

@ -25,7 +25,8 @@ publish.workspace = true
#
# The stream organ (`stream`, the `mind-stream` binary) builds its Projector prompt with Ghostlight's
# `ghostlight-persona-projection`, pinned to a revision, so the membrane's text stays Ghostlight's; it
# speaks to a model host on loopback over `ureq` 3 with default features off (plain HTTP, no TLS), as the
# asks its Projector through the connector kit's client (`model-connector`, one turn per tick) and speaks to the base
# model's host on loopback over `ureq` 3 with default features off (plain HTTP, no TLS), as the
# daemon's embedder does, and stays below 3.4 for the same reason.
#
# `serde_json` reads Claude Code's stream-json, a foreign owner's wire (`self_run`);
@ -63,6 +64,9 @@ proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
tempfile = "3"
# A real kit server (`serve` over a scripted `ProviderBackend`) proves the stream's Projector wire, not a fake of it.
# Same revision as the dependency; the `daemon` feature is test-only.
model-connector = { git = "https://eureka.gamecult.org/forge/GameCult/ModelConnector.git", rev = "c1802d85bee3ae04b0c5737563c8343c1fef515d", package = "model-connector", features = ["daemon"] }
[lib]
name = "mind_body"

View file

@ -6,7 +6,7 @@
use eureka_pipeline::{OrgRepo, Short};
use crate::chat_client::ChatDelivery;
use crate::chat_client::{Basis, ChatDelivery, Form};
/// A word a message may call a Mind or her project by.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@ -16,25 +16,34 @@ pub struct Name {
pub(crate) exact: bool,
}
/// Whom a delivery may be addressing: one Mind's Matrix id and her names.
/// Whom a delivery may be addressing: one Mind's Matrix id, her names and the repos she stewards.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Addressee {
matrix_id: String,
names: Vec<Name>,
/// The repos her in-force stewardships name, as the one read that gave `names` found them: what a ruling of hers may be decided under.
#[serde(default)]
repos: Vec<OrgRepo>,
}
impl Addressee {
/// A Mind by her id and her own names, each matched in any case.
pub(crate) fn new<'a>(matrix_id: &str, own_names: impl IntoIterator<Item = &'a str>) -> Self {
Self { matrix_id: matrix_id.to_string(), names: own_names.into_iter().map(|text| Name { text: text.to_string(), exact: false }).collect() }
Self { matrix_id: matrix_id.to_string(), names: own_names.into_iter().map(|text| Name { text: text.to_string(), exact: false }).collect(), repos: Vec::new() }
}
/// The same Mind, also addressed by her projects' names.
pub(crate) fn with_projects(mut self, names: impl IntoIterator<Item = Name>) -> Self {
/// The same Mind, also addressed by her projects' names, with the repos those stewardships name.
pub(crate) fn with_stewardships(mut self, names: impl IntoIterator<Item = Name>, repos: impl IntoIterator<Item = OrgRepo>) -> Self {
self.names.extend(names);
self.repos.extend(repos);
self
}
/// The repos she stewards, as the heartbeat last read them.
pub(crate) fn repos(&self) -> &[OrgRepo] {
&self.repos
}
pub(crate) fn matrix_id(&self) -> &str {
&self.matrix_id
}
@ -46,9 +55,17 @@ pub(crate) fn localpart(matrix_id: &str) -> &str {
id.split_once(':').map_or(id, |(local, _)| local)
}
/// Whether a delivery is for `who`: tagged, or named by a word of hers.
/// Whether a delivery is for `who` by what it says: only a message can be (a notice or an edit never is, whatever else it says), and
/// then a tag of hers in its mentions or a word of hers in the text. What Matrix says about the Persona that polled (`Basis`) is not
/// here: it is a fact about that one Persona, not about every Mind in the table (`is_for`).
pub(crate) fn addresses(who: &Addressee, delivery: &ChatDelivery) -> bool {
delivery.mentions.contains(&who.matrix_id) || who.names.iter().any(|name| names_whole_word(&delivery.text, &name.text, name.exact))
delivery.form == Form::Message && (delivery.mentions.contains(&who.matrix_id) || who.names.iter().any(|name| names_whole_word(&delivery.text, &name.text, name.exact)))
}
/// Whether a delivery the mouth gave `own` is for her: her DM (`is_direct`, which every mouth sends, so an old mouth's DM is hers too),
/// Matrix's word that it is a tag of hers or a reply to her, or what it says (`addresses`). The basis `Room` (`@room`) is for no one, and no basis is unproven, not "not for her": the names still decide.
pub(crate) fn is_for(own: &Addressee, delivery: &ChatDelivery) -> bool {
(delivery.form == Form::Message && (delivery.is_direct || matches!(delivery.basis, Some(Basis::Direct | Basis::Tag | Basis::Reply)))) || addresses(own, delivery)
}
/// The chat names of one stewarded repo. Listed names are exactly those, each matched exactly as the steward wrote it (capitalization

View file

@ -1,6 +1,7 @@
//! The one addressing rule: a tag, or a name as a whole word; a listed name exactly as written, a derived one-word repo name as the repo spells it, a split phrase or a Mind's own in any case.
use super::*;
use crate::chat_client::{Basis, Form};
fn said(text: &str, mentions: &[&str]) -> ChatDelivery {
ChatDelivery {
@ -10,6 +11,8 @@ fn said(text: &str, mentions: &[&str]) -> ChatDelivery {
room_alias: "swarm".into(),
is_direct: false,
image: None,
basis: None,
form: Form::Message,
sender: "@op:chat".into(),
sender_display: "Op".into(),
at_unix_ms: 0,
@ -24,7 +27,7 @@ fn names(texts: &[&str]) -> Vec<Short> {
}
fn steward(repo: &str, listed: &[&str]) -> Addressee {
Addressee::new("@nibu:chat", ["Nibu"]).with_projects(project_names(&OrgRepo(repo.into()), &names(listed)))
Addressee::new("@nibu:chat", ["Nibu"]).with_stewardships(project_names(&OrgRepo(repo.into()), &names(listed)), [OrgRepo(repo.into())])
}
#[test]
@ -61,7 +64,7 @@ fn project_names_derives_the_repo_name_and_its_split_form_and_listed_names_repla
#[test]
fn a_listed_name_matches_exactly_as_written_a_derived_one_word_name_as_spelled_and_a_split_phrase_in_any_case() {
let libby = Addressee::new("@libby:chat", ["Libby"]).with_projects(project_names(&OrgRepo("GameCult/Thing".into()), &names(&["Thing"])));
let libby = Addressee::new("@libby:chat", ["Libby"]).with_stewardships(project_names(&OrgRepo("GameCult/Thing".into()), &names(&["Thing"])), [OrgRepo("GameCult/Thing".into())]);
assert!(addresses(&libby, &said("Thing is down", &[])));
assert!(!addresses(&libby, &said("one thing at a time", &[])));
assert!(!addresses(&libby, &said("THING", &[])));
@ -84,11 +87,11 @@ fn a_listed_name_matches_exactly_as_written_a_derived_one_word_name_as_spelled_a
#[test]
fn one_name_addresses_every_mind_that_lists_it_each_on_her_own_record() {
let lore = steward("GameCult/AetheriaLore", &[]);
let aetheria = Addressee::new("@aqua:chat", ["Aqua"]).with_projects(project_names(&OrgRepo("GameCult/Aetheria".into()), &[]));
let aetheria = Addressee::new("@aqua:chat", ["Aqua"]).with_stewardships(project_names(&OrgRepo("GameCult/Aetheria".into()), &[]), [OrgRepo("GameCult/Aetheria".into())]);
let both = said("Aetheria lore needs a pass", &[]);
assert!(addresses(&aetheria, &both), "a name inside another Mind's longer name addresses her too");
assert!(addresses(&lore, &both));
let two_stewards = [steward("GameCult/Thing", &["Thing"]), Addressee::new("@libby:chat", ["Libby"]).with_projects(project_names(&OrgRepo("GameCult/Thing".into()), &[]))];
let two_stewards = [steward("GameCult/Thing", &["Thing"]), Addressee::new("@libby:chat", ["Libby"]).with_stewardships(project_names(&OrgRepo("GameCult/Thing".into()), &[]), [OrgRepo("GameCult/Thing".into())])];
assert!(two_stewards.iter().all(|who| addresses(who, &said("Thing is broken", &[]))), "a project with two stewards addresses each");
assert!(!addresses(&two_stewards[0], &said("a thing", &[])), "listed Thing is exact");
assert!(!addresses(&two_stewards[1], &said("a thing", &[])), "derived Thing is exact as the repo spells it");
@ -108,3 +111,29 @@ fn the_localpart_is_the_name_between_the_at_and_the_colon() {
assert_eq!(localpart("@eureka:chat.gamecult.org"), "eureka");
assert_eq!(localpart("eureka"), "eureka");
}
#[test]
fn only_a_message_can_address_and_the_mouths_basis_stands_for_the_polling_mind_alone() {
let nibu = steward("GameCult/Thing", &[]);
let with = |basis, form, text: &str, mentions: &[&str]| ChatDelivery { basis, form, ..said(text, mentions) };
for basis in [Basis::Direct, Basis::Tag, Basis::Reply] {
assert!(is_for(&nibu, &with(Some(basis), Form::Message, "nothing named", &[])), "{basis:?}");
assert!(!addresses(&nibu, &with(Some(basis), Form::Message, "nothing named", &[])), "a basis is a fact about the Persona that polled, not about every Mind: {basis:?}");
assert!(!is_for(&nibu, &with(Some(basis), Form::Notice, "nothing named", &[])), "a notice is for no one: {basis:?}");
assert!(!is_for(&nibu, &with(Some(basis), Form::Edit, "nibu, look", &["@nibu:chat"])), "nor an edit, tagged and named: {basis:?}");
assert!(!addresses(&nibu, &with(None, Form::Edit, "nibu, look", &["@nibu:chat"])), "{basis:?}");
}
assert!(!is_for(&nibu, &with(Some(Basis::Room), Form::Message, "nothing named", &[])), "@room is for no one");
assert!(!is_for(&nibu, &with(None, Form::Message, "nothing named", &[])), "unproven and unnamed");
assert!(is_for(&nibu, &with(None, Form::Message, "nibu, look", &[])), "unproven is not 'not for her': her name decides");
assert!(is_for(&nibu, &with(Some(Basis::Room), Form::Message, "nibu, look", &[])), "an @room that also names her is named");
}
#[test]
fn a_direct_message_is_for_her_whatever_the_basis() {
let nibu = steward("GameCult/Thing", &[]);
let dm = ChatDelivery { is_direct: true, ..said("nothing named", &[]) };
assert!(is_for(&nibu, &dm), "an old mouth sends no basis; the DM is hers");
assert!(!is_for(&nibu, &ChatDelivery { form: Form::Notice, ..dm.clone() }));
assert!(!is_for(&nibu, &ChatDelivery { form: Form::Edit, ..dm }));
}

View file

@ -7,10 +7,14 @@
//! the library never does.
//!
//! Environment: `HUGINN_ENDPOINT` (`rudp://<ip>:<port>`, her own daemon), `EUREKA_INSTANCE`
//! (when `--instance` is not given), `MIND_STREAM_ENDPOINT` (`http://<loopback>:<port>`, the
//! model host), `MIND_STREAM_PROJECTOR_MODEL` and `MIND_STREAM_BASE_MODEL`.
//! (when `--instance` is not given), `MIND_STREAM_CONNECTOR_ENDPOINT` (`<loopback ip>:<port>`, the
//! connector kit the Projector is asked through), `MIND_STREAM_PROJECTOR_MODEL` (the model id the
//! kit's caller row allows), `MIND_STREAM_ENDPOINT` (`http://<loopback>:<port>`, the model host) and
//! `MIND_STREAM_BASE_MODEL`. The connector key is the file `connector-key` in `CREDENTIALS_DIRECTORY`
//! (systemd's `LoadCredential`), read once; the caller id is the instance.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
@ -20,15 +24,23 @@ use eureka_pipeline::Slug;
use eureka_state::HuginnClient;
use mind_body::control::read_effective;
use mind_body::queue::HuginnMind;
use mind_body::stream::{HuginnPersonaSource, Mode, OllamaStream, Organs, Outcome, STREAM_MIN_GAP_S, STREAM_POLL_S, tick};
use mind_body::stream::{HuginnPersonaSource, KitProjector, Mode, OllamaContinuer, Organs, Outcome, STREAM_MIN_GAP_S, STREAM_POLL_S, connector_key, tick};
const TIMEOUT: Duration = Duration::from_secs(30);
/// How long the kit may take to answer one Projector turn.
const PROJECTOR_TIMEOUT: Duration = Duration::from_secs(300);
#[allow(clippy::disallowed_methods, reason = "the binary reads its own environment, and hands it to the library")]
fn setting(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
/// The credentials directory systemd hands the unit; its absence names the variable.
#[allow(clippy::disallowed_methods, reason = "the binary reads its own environment, and hands it to the library")]
fn credentials_directory() -> Result<PathBuf> {
std::env::var_os("CREDENTIALS_DIRECTORY").map(PathBuf::from).ok_or_else(|| anyhow!("CREDENTIALS_DIRECTORY is not set"))
}
fn required(name: &str) -> Result<String> {
setting(name).ok_or_else(|| anyhow!("{name} is not set"))
}
@ -63,10 +75,12 @@ fn run() -> Result<()> {
.ok_or_else(|| anyhow!("HUGINN_ENDPOINT is not rudp://<ip>:<port>"))?;
let projector_model = required("MIND_STREAM_PROJECTOR_MODEL")?;
let base_model = required("MIND_STREAM_BASE_MODEL")?;
let model = OllamaStream::open(&required("MIND_STREAM_ENDPOINT")?, &projector_model, &base_model)?;
let connector: SocketAddr = required("MIND_STREAM_CONNECTOR_ENDPOINT")?.parse().map_err(|_| anyhow!("MIND_STREAM_CONNECTOR_ENDPOINT is not <ip>:<port>"))?;
let projector = KitProjector::open(connector, connector_key(&credentials_directory()?)?, &instance.0, &projector_model, PROJECTOR_TIMEOUT)?;
let continuer = OllamaContinuer::open(&required("MIND_STREAM_ENDPOINT")?, &base_model)?;
let mind = HuginnMind::new(HuginnClient::new(address, instance.clone(), TIMEOUT));
let persona = HuginnPersonaSource::new(HuginnClient::new(address, instance.clone(), TIMEOUT));
let organs = Organs { mind: &mind, persona: &persona, model: &model, projector_model: &projector_model, base_model: &base_model };
let organs = Organs { mind: &mind, persona: &persona, projector: &projector, continuer: &continuer, base_model: &base_model };
let mode = if args.dry_run { Mode::DryRun } else { Mode::Live };
let once = args.once || args.dry_run;
loop {

View file

@ -4,8 +4,8 @@
//! The mouth is Bifrost's chat ear and mouth (cut `persona-mouth`). Five CultNet document kinds cross
//! it, each a positional MessagePack tuple (fields in declaration order, `rmp_serde::to_vec`; the mouth refuses maps): the pending request and its answer, the
//! ack, and the send request with its receipt. Their field order is the mouth's, pinned
//! by its golden fixtures (`tests/fixtures/persona-chat-v1/` for the pending, ack and refusal kinds, `persona-chat-v2/` for the send
//! and its receipt) in both directions. `chat_rudp::RudpChat` is
//! by its golden fixtures (`tests/fixtures/persona-chat-v1/` for the ack and refusal kinds, `persona-chat-v2/` for the send
//! and its receipt, `persona-chat-v5/` for the pending request and its answer) in both directions. `chat_rudp::RudpChat` is
//! the RUDP transport that carries them; `Chat` is its seam, and `encode` and
//! `decode` are its codec. Room aliases are the mouth's
//! full aliases (`#operator:gamecult.org`), which the caller's `--rooms` carries.
@ -17,15 +17,24 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
/// Document kinds, as the mouth names them.
pub const PENDING_REQUEST: &str = "gamecult.persona.chat_pending_request.v1";
pub const PENDING: &str = "gamecult.persona.chat_pending.v2";
/// The pending answer of a mouth that has no image field yet: its deliveries decode with `image: None`.
/// The pending request as a mouth without the addressing facts reads it: `[persona, after_seq]`, answered with `chat_pending.v2`.
pub const PENDING_REQUEST_V1: &str = "gamecult.persona.chat_pending_request.v1";
/// The pending request, the same tuple. Asking by this kind is asking for the answer with the addressing facts.
pub const PENDING_REQUEST: &str = "gamecult.persona.chat_pending_request.v2";
/// The answer to it: a delivery with its `basis` and `form` at tuple indices 12 and 13.
pub const PENDING: &str = "gamecult.persona.chat_pending.v3";
/// The answer a mouth gives a v1 request: no addressing facts, decoded as `basis: None, form: Message`.
pub const PENDING_V2: &str = "gamecult.persona.chat_pending.v2";
/// The pending answer of a mouth that has no image field either: its deliveries decode with `image: None`.
pub const PENDING_V1: &str = "gamecult.persona.chat_pending.v1";
pub const ACK: &str = "gamecult.persona.chat_ack.v1";
/// Her read receipt: the newest event of a room that went into her model's input.
pub const READ: &str = "gamecult.persona.chat_read.v1";
pub const SEND_REQUEST: &str = "gamecult.persona.chat_send_request.v2";
pub const SEND_RECEIPT: &str = "gamecult.persona.chat_send_receipt.v2";
/// Her request that the mouth open a DM with a person, and its answer.
pub const OPEN_DIRECT_REQUEST: &str = "gamecult.persona.chat_open_direct_request.v1";
pub const OPEN_DIRECT_RECEIPT: &str = "gamecult.persona.chat_open_direct_receipt.v1";
pub const REFUSAL: &str = "gamecult.persona.chat_refusal.v1";
/// One earlier line of a room, delivered with a message for its context.
@ -55,6 +64,87 @@ pub struct ChatDelivery {
/// The attachment the mouth stored for an image event. Tuple index 11; a v1 record ends at 10 and reads as none.
#[serde(default)]
pub image: Option<ChatImage>,
/// Why Matrix says the message is for this Persona: tuple index 12. None is unproven, never "not for her"; an answer of an
/// older mouth ends before it and reads as none.
#[serde(default)]
pub basis: Option<Basis>,
/// What kind of event it is: tuple index 13. An answer of an older mouth ends before it and reads as a message.
#[serde(default)]
pub form: Form,
}
/// Why the mouth found a delivery to be for the Persona, by what Matrix says (Bifrost `docs/bridge.md` "Addressing facts"). `Tag` and
/// `Room` are the sender's claim in `m.mentions`; `Reply` is checked against the event it targets. Evidence, not authority: the
/// runtime's own rule (`address::addresses`) weighs it. `Room` is reported and wakes no one.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Basis {
Direct,
Tag,
Reply,
Room,
}
impl Basis {
const ALL: [Self; 4] = [Self::Direct, Self::Tag, Self::Reply, Self::Room];
/// The mouth's spelling: a MessagePack string.
pub fn name(self) -> &'static str {
match self {
Self::Direct => "direct",
Self::Tag => "tag",
Self::Reply => "reply",
Self::Room => "room",
}
}
}
impl Serialize for Basis {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.name())
}
}
impl<'de> Deserialize<'de> for Basis {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let name = String::deserialize(deserializer)?;
Self::ALL.into_iter().find(|basis| basis.name() == name).ok_or_else(|| serde::de::Error::custom("the mouth named a basis this side does not know"))
}
}
/// The kind of event a delivery is. Only a `Message` can address anyone: a `Notice` is an automated line and an `Edit` revises an
/// earlier message.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Form {
#[default]
Message,
Notice,
Edit,
}
impl Form {
const ALL: [Self; 3] = [Self::Message, Self::Notice, Self::Edit];
/// The mouth's spelling: a MessagePack string.
pub fn name(self) -> &'static str {
match self {
Self::Message => "message",
Self::Notice => "notice",
Self::Edit => "edit",
}
}
}
impl Serialize for Form {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.name())
}
}
impl<'de> Deserialize<'de> for Form {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let name = String::deserialize(deserializer)?;
Self::ALL.into_iter().find(|form| form.name() == name).ok_or_else(|| serde::de::Error::custom("the mouth named a form this side does not know"))
}
}
/// An image the mouth fetched and stored, by reference: never its bytes. The path is relative to the Persona's image
@ -78,6 +168,28 @@ pub struct ChatPendingRequest {
pub struct ChatPending {
pub persona: String,
pub deliveries: Vec<ChatDelivery>,
/// The mouth's room tail, tuple index 2 of `chat_pending.v3`: recent lines of the rooms she may speak in, oldest first, whether
/// or not delivered or acked. An answer of a mouth that has none (v1, v2, or a v3 before the tail) ends before it and reads as
/// none; a mouth with nothing to show sends an empty list, and that is a different fact.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tail: Option<Vec<TailLine>>,
/// The rooms whose lines are not all in `tail`, tuple index 3: a room the mouth could not read in time, or whose lines it trimmed. A
/// reader renders such a room as unknown, never quiet. An answer that ends before the field, or an empty list, names none.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tail_gaps: Vec<String>,
}
/// One line of the room tail: `[room, event_id, at_unix_ms, sender, form, basis, body]`. `room` is the configured alias (the room id
/// for a DM); `basis` is what Matrix says on the event itself, none being unproven; `body` is untrusted text clipped by the mouth.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TailLine {
pub room: String,
pub event_id: String,
pub at_unix_ms: u64,
pub sender: String,
pub form: Form,
pub basis: Option<Basis>,
pub body: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -112,6 +224,8 @@ pub enum Refusal {
UnknownPersona,
/// A send with a basis found the room had moved past it: the receipt names what moved it.
RoomMoved,
/// The mouth does not serve this document kind. Answers an open-direct request only: a send receipt never carries it.
UnknownKind,
}
impl Refusal {
@ -125,6 +239,7 @@ impl Refusal {
Self::SendFailed => "SendFailed",
Self::UnknownPersona => "UnknownPersona",
Self::RoomMoved => "RoomMoved",
Self::UnknownKind => "UnknownKind",
}
}
}
@ -138,13 +253,26 @@ impl Serialize for Refusal {
impl<'de> Deserialize<'de> for Refusal {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let name = String::deserialize(deserializer)?;
[Self::Brake, Self::PersonaMismatch, Self::UnknownRoom, Self::EmptyText, Self::SendFailed, Self::UnknownPersona, Self::RoomMoved]
[Self::Brake, Self::PersonaMismatch, Self::UnknownRoom, Self::EmptyText, Self::SendFailed, Self::UnknownPersona, Self::RoomMoved, Self::UnknownKind]
.into_iter()
.find(|refusal| refusal.name() == name)
.ok_or_else(|| serde::de::Error::custom("the mouth named a refusal this side does not know"))
}
}
/// The mouth refused a request outright with a typed refusal frame, and nothing was done. An error of `open_direct` when the
/// mouth does not serve the kind: told as not sent, unlike a silent mouth, whose open may have gone out.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MouthRefused(pub Refusal);
impl std::fmt::Display for MouthRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "the mouth refused the request: {}", self.0.name())
}
}
impl std::error::Error for MouthRefused {}
/// The mouth's typed answer to a request that names a Persona it does not
/// serve: the pending request, the ack and the send alike.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -183,6 +311,117 @@ pub struct ChatSendReceipt {
pub newer: Vec<String>,
}
/// A request that the mouth open a DM with `user_id`, `text` being the invite: the ask, and the first line of the room.
/// `request_id` is the mouth's transaction id, so a repeat of the same id is the same open. The mouth bounds `text` to
/// 1..=4000 bytes.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChatOpenDirectRequest {
pub request_id: String,
pub persona: String,
pub user_id: String,
pub text: String,
}
/// How the mouth answered an open, as its typed names. It reads consent from Matrix room state; none is kept here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DirectOutcome {
Opened,
Existing,
Brake,
PersonaMismatch,
UnknownPersona,
EmptyText,
ForeignUser,
NotAPerson,
NoSuchUser,
InvitePending,
Declined,
/// One creation per person per UTC day.
DailyLimit,
SendFailed,
}
impl DirectOutcome {
const ALL: [Self; 13] = [
Self::Opened,
Self::Existing,
Self::Brake,
Self::PersonaMismatch,
Self::UnknownPersona,
Self::EmptyText,
Self::ForeignUser,
Self::NotAPerson,
Self::NoSuchUser,
Self::InvitePending,
Self::Declined,
Self::DailyLimit,
Self::SendFailed,
];
/// The mouth's spelling: a MessagePack string.
pub fn name(self) -> &'static str {
match self {
Self::Opened => "Opened",
Self::Existing => "Existing",
Self::Brake => "Brake",
Self::PersonaMismatch => "PersonaMismatch",
Self::UnknownPersona => "UnknownPersona",
Self::EmptyText => "EmptyText",
Self::ForeignUser => "ForeignUser",
Self::NotAPerson => "NotAPerson",
Self::NoSuchUser => "NoSuchUser",
Self::InvitePending => "InvitePending",
Self::Declined => "Declined",
Self::DailyLimit => "DailyLimit",
Self::SendFailed => "SendFailed",
}
}
}
impl Serialize for DirectOutcome {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.name())
}
}
impl<'de> Deserialize<'de> for DirectOutcome {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let name = String::deserialize(deserializer)?;
Self::ALL.into_iter().find(|outcome| outcome.name() == name).ok_or_else(|| serde::de::Error::custom("the mouth named an outcome this side does not know"))
}
}
/// The mouth's answer to an open. `room_id` and `event_id` are present exactly for `Opened` and `Existing`; a receipt
/// that says otherwise does not decode.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "RawOpenReceipt")]
pub struct ChatOpenDirectReceipt {
pub request_id: String,
pub outcome: DirectOutcome,
pub room_id: Option<String>,
pub event_id: Option<String>,
}
#[derive(Deserialize)]
struct RawOpenReceipt {
request_id: String,
outcome: DirectOutcome,
room_id: Option<String>,
event_id: Option<String>,
}
impl TryFrom<RawOpenReceipt> for ChatOpenDirectReceipt {
type Error = &'static str;
fn try_from(raw: RawOpenReceipt) -> Result<Self, Self::Error> {
let landed = matches!(raw.outcome, DirectOutcome::Opened | DirectOutcome::Existing);
if landed != (raw.room_id.is_some() && raw.event_id.is_some()) || (!landed && (raw.room_id.is_some() || raw.event_id.is_some())) {
return Err("the open receipt's ids do not match its outcome");
}
Ok(Self { request_id: raw.request_id, outcome: raw.outcome, room_id: raw.room_id, event_id: raw.event_id })
}
}
/// A document as the mouth reads it: a positional tuple.
pub fn encode<T: Serialize>(document: &T) -> Result<Vec<u8>> {
rmp_serde::to_vec(document).context("the chat document does not encode")
@ -195,10 +434,12 @@ pub fn decode<T: DeserializeOwned>(bytes: &[u8]) -> Result<T> {
/// The Persona organ's chat: what is waiting, what to say, what is done.
pub trait Chat {
/// Deliveries after `after_seq`, oldest first. A `chat_refusal` answer is an
/// Deliveries after `after_seq`, oldest first, with the mouth's room tail. A `chat_refusal` answer is an
/// error that names the refusal.
fn pending(&self, after_seq: u64) -> Result<Vec<ChatDelivery>>;
fn pending(&self, after_seq: u64) -> Result<ChatPending>;
fn send(&self, request: &ChatSendRequest) -> Result<ChatSendReceipt>;
/// Ask the mouth to open a DM. A refusal is a receipt; only a transport fault or a mouth that does not serve the kind is an error.
fn open_direct(&self, request: &ChatOpenDirectRequest) -> Result<ChatOpenDirectReceipt>;
/// Every delivery up to and including `through_seq` is answered; the mouth
/// advances its sync position only now, so a start that dies first has its
/// deliveries again.

View file

@ -10,12 +10,27 @@ const ACK_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/ga
const PENDING_V1_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending.v1.msgpack");
const PENDING_NO_IMAGE_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v3/gamecult.persona.chat_pending.v2.no-image.msgpack");
const PENDING_IMAGE_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v3/gamecult.persona.chat_pending.v2.image.msgpack");
const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack");
const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending_request.v2.msgpack");
const PENDING_REQUEST_V1_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack");
const PENDING_TAG_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack");
const PENDING_UNPROVEN_NOTICE_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.unproven-notice.msgpack");
// The three-field `chat_pending.v3` of a mouth with the room tail: Bifrost e8722d0's own golden bytes (`tests/fixtures/persona-chat-v5/`
// there), written by its encoder `pendingV3Tuple`, copied here unchanged. The v5 files above are the two-field answer of a mouth before it.
const PENDING_TAG_TAILED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.tag.msgpack");
const PENDING_UNPROVEN_NOTICE_TAILED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.unproven-notice.msgpack");
const PENDING_TAIL_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.tail.msgpack");
// The four-field `chat_pending.v3` with `tail_gaps`: Bifrost b2e0bb2's own golden bytes, copied unchanged.
const PENDING_GAPS_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v7/gamecult.persona.chat_pending.v3.tail-gaps.msgpack");
const SENT_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v2/gamecult.persona.chat_send_receipt.v2.sent.msgpack");
const ROOM_MOVED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v2/gamecult.persona.chat_send_receipt.v2.room-moved.msgpack");
const NIL_BASIS_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v2/gamecult.persona.chat_send_request.v2.nil-basis.msgpack");
const SEEN_AND_SINCE_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v2/gamecult.persona.chat_send_request.v2.seen-and-since.msgpack");
const SINCE_ONLY_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v2/gamecult.persona.chat_send_request.v2.since-only.msgpack");
const OPEN_REQUEST_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_request.v1.msgpack");
const OPENED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_receipt.v1.opened.msgpack");
const EXISTING_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_receipt.v1.existing.msgpack");
const DECLINED_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_receipt.v1.declined.msgpack");
const UNKNOWN_KIND_BYTES: &[u8] = include_bytes!("../../tests/fixtures/persona-chat-v4/gamecult.persona.chat_refusal.v1.unknown-kind.msgpack");
fn golden<T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug>(bytes: &[u8], expected: &T) {
assert_eq!(&decode::<T>(bytes).unwrap(), expected);
@ -26,6 +41,8 @@ fn golden<T: Serialize + DeserializeOwned + PartialEq + std::fmt::Debug>(bytes:
#[test]
fn the_pending_request_and_ack_are_the_mouths_bytes() {
golden(PENDING_REQUEST_BYTES, &ChatPendingRequest { persona: "eureka".into(), after_seq: 3 });
// The v1 kind carries the same tuple: asking by the v2 kind is the only difference.
assert_eq!(PENDING_REQUEST_V1_BYTES, PENDING_REQUEST_BYTES);
golden(ACK_BYTES, &ChatAck { persona: "eureka".into(), through_seq: 7 });
}
@ -43,6 +60,8 @@ fn delivery(image: Option<ChatImage>) -> ChatDelivery {
mentions: vec!["@eureka:gamecult.org".into(), "@nova:gamecult.org".into()],
is_direct: true,
image,
basis: None,
form: Form::Message,
}
}
@ -54,18 +73,70 @@ fn image() -> ChatImage {
#[test]
fn a_v1_pending_answer_still_decodes_with_no_image() {
let pending: ChatPending = decode(PENDING_V1_BYTES).unwrap();
assert_eq!(pending, ChatPending { persona: "eureka".into(), deliveries: vec![delivery(None)] });
assert_eq!(pending, ChatPending { persona: "eureka".into(), deliveries: vec![delivery(None)], tail: None, tail_gaps: vec![] });
assert_eq!(PENDING_V1_BYTES[0] & 0xf0, 0x90, "a positional tuple");
}
#[test]
fn a_v2_pending_answer_is_the_golden_bytes_with_the_image_at_tuple_index_11() {
golden(PENDING_NO_IMAGE_BYTES, &ChatPending { persona: "eureka".into(), deliveries: vec![delivery(None)] });
golden(PENDING_IMAGE_BYTES, &ChatPending { persona: "eureka".into(), deliveries: vec![delivery(Some(image()))] });
fn a_v2_pending_answer_decodes_with_the_image_at_tuple_index_11_and_no_addressing_facts() {
let expected = |image| ChatPending { persona: "eureka".into(), deliveries: vec![delivery(image)], tail: None, tail_gaps: vec![] };
assert_eq!(decode::<ChatPending>(PENDING_NO_IMAGE_BYTES).unwrap(), expected(None));
assert_eq!(decode::<ChatPending>(PENDING_IMAGE_BYTES).unwrap(), expected(Some(image())));
// Index 11 of the delivery tuple, by position: 12 elements, the last an array of [path, mime, size, sha256].
let value = shape::delivery_tuple(PENDING_IMAGE_BYTES);
assert_eq!(value, shape::Probe { delivery_len: 12, image_len: Some(4) });
assert_eq!(shape::delivery_tuple(PENDING_IMAGE_BYTES), shape::Probe { delivery_len: 12, image_len: Some(4) });
assert_eq!(shape::delivery_tuple(PENDING_NO_IMAGE_BYTES), shape::Probe { delivery_len: 12, image_len: None });
// A v1 request is answered with that 12-field tuple: no basis is unproven, and the event is a message.
let old = decode::<ChatPending>(PENDING_IMAGE_BYTES).unwrap().deliveries.remove(0);
assert_eq!((old.basis, old.form), (None, Form::Message));
}
#[test]
fn a_v3_pending_answer_is_the_golden_bytes_with_basis_and_form_at_tuple_indices_12_and_13() {
let one = |is_direct, mentions: &[&str], basis, form| {
let mut delivery = delivery(None);
(delivery.is_direct, delivery.mentions, delivery.basis, delivery.form) = (is_direct, mentions.iter().map(|id| (*id).to_string()).collect(), basis, form);
ChatPending { persona: "eureka".into(), deliveries: vec![delivery], tail: None, tail_gaps: vec![] }
};
golden(PENDING_TAG_BYTES, &one(false, &["@eureka:gamecult.org", "@nova:gamecult.org"], Some(Basis::Tag), Form::Message));
golden(PENDING_UNPROVEN_NOTICE_BYTES, &one(false, &[], None, Form::Notice));
let fields = |bytes: &[u8]| {
let value: serde_json::Value = rmp_serde::from_slice(bytes).unwrap();
let tuple = value[1][0].as_array().unwrap().clone();
(tuple.len(), tuple[12].clone(), tuple[13].clone())
};
assert_eq!(fields(PENDING_TAG_BYTES), (14, "tag".into(), "message".into()));
assert_eq!(fields(PENDING_UNPROVEN_NOTICE_BYTES), (14, serde_json::Value::Null, "notice".into()));
}
/// Deploy safety: an old mouth's two-field v3 and a new mouth's three-field v3 both decode; only the new one has a tail.
#[test]
fn the_pending_answer_decodes_with_the_tail_field_and_without_it() {
let one = |is_direct, mentions: &[&str], basis, form, tail| {
let mut delivery = delivery(None);
(delivery.is_direct, delivery.mentions, delivery.basis, delivery.form) = (is_direct, mentions.iter().map(|id| (*id).to_string()).collect(), basis, form);
ChatPending { persona: "eureka".into(), deliveries: vec![delivery], tail, tail_gaps: vec![] }
};
for old in [PENDING_TAG_BYTES, PENDING_UNPROVEN_NOTICE_BYTES, PENDING_V1_BYTES, PENDING_NO_IMAGE_BYTES] {
assert_eq!(decode::<ChatPending>(old).unwrap().tail, None, "a mouth with no tail sends none");
}
golden(PENDING_TAG_TAILED_BYTES, &one(false, &["@eureka:gamecult.org", "@nova:gamecult.org"], Some(Basis::Tag), Form::Message, Some(vec![])));
golden(PENDING_UNPROVEN_NOTICE_TAILED_BYTES, &one(false, &[], None, Form::Notice, Some(vec![])));
let tailed: ChatPending = decode(PENDING_TAIL_BYTES).unwrap();
assert!(tailed.deliveries.is_empty());
let line = |event: &str, at, form, basis, body: &str| TailLine { room: "#operator:gamecult.org".into(), event_id: event.into(), at_unix_ms: at, sender: "@meta:gamecult.org".into(), form, basis, body: body.into() };
assert_eq!(tailed.tail, Some(vec![line("$e0", 1_700_000_000_000, Form::Message, None, "earlier"), line("$e1", 1_700_000_001_000, Form::Notice, Some(Basis::Tag), "hello eureka")]));
golden(PENDING_TAIL_BYTES, &tailed);
assert_eq!(rmp_serde::from_slice::<serde_json::Value>(PENDING_TAIL_BYTES).unwrap()[2][0].as_array().unwrap().len(), 7, "a tail line is seven fields");
}
#[test]
fn a_basis_or_form_this_side_does_not_know_does_not_decode() {
let mut value: serde_json::Value = rmp_serde::from_slice(PENDING_TAG_BYTES).unwrap();
value[1][0][12] = "everyone".into();
assert!(decode::<ChatPending>(&rmp_serde::to_vec(&value).unwrap()).is_err());
value[1][0][12] = "tag".into();
value[1][0][13] = "reaction".into();
assert!(decode::<ChatPending>(&rmp_serde::to_vec(&value).unwrap()).is_err());
}
/// Reads the tuple's shape from the raw bytes, independent of the serde types under test.
@ -99,11 +170,74 @@ fn a_send_and_its_receipt_are_the_mouths_bytes() {
golden(ROOM_MOVED_BYTES, &ChatSendReceipt { request_id: "req-1".into(), event_id: None, refusal: Some(Refusal::RoomMoved), newer: vec!["$a".into(), "$b".into()] });
}
/// Writes the v2 fixtures; run once with `WRITE_CHAT_FIXTURES=1` when the wire changes.
#[test]
#[ignore = "regenerates the golden fixtures"]
fn write_the_v2_pending_fixtures() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/persona-chat-v3/");
std::fs::write(format!("{dir}gamecult.persona.chat_pending.v2.no-image.msgpack"), encode(&ChatPending { persona: "eureka".into(), deliveries: vec![delivery(None)] }).unwrap()).unwrap();
std::fs::write(format!("{dir}gamecult.persona.chat_pending.v2.image.msgpack"), encode(&ChatPending { persona: "eureka".into(), deliveries: vec![delivery(Some(image()))] }).unwrap()).unwrap();
fn open_direct_fixtures_round_trip() {
golden(OPEN_REQUEST_BYTES, &ChatOpenDirectRequest { request_id: "req-1".into(), persona: "eureka".into(), user_id: "@meta:gamecult.org".into(), text: "hello Meta".into() });
golden(OPENED_BYTES, &ChatOpenDirectReceipt { request_id: "req-1".into(), outcome: DirectOutcome::Opened, room_id: Some("!dm1:gamecult.org".into()), event_id: Some("$line1".into()) });
golden(EXISTING_BYTES, &ChatOpenDirectReceipt { request_id: "req-2".into(), outcome: DirectOutcome::Existing, room_id: Some("!dm1:gamecult.org".into()), event_id: Some("$line2".into()) });
golden(DECLINED_BYTES, &ChatOpenDirectReceipt { request_id: "req-3".into(), outcome: DirectOutcome::Declined, room_id: None, event_id: None });
golden(UNKNOWN_KIND_BYTES, &ChatRefusal { persona: "eureka".into(), refusal: Refusal::UnknownKind });
}
#[test]
fn every_outcome_is_spelled_as_the_mouth_spells_it() {
let all = [
(DirectOutcome::Opened, "Opened"),
(DirectOutcome::Existing, "Existing"),
(DirectOutcome::Brake, "Brake"),
(DirectOutcome::PersonaMismatch, "PersonaMismatch"),
(DirectOutcome::UnknownPersona, "UnknownPersona"),
(DirectOutcome::EmptyText, "EmptyText"),
(DirectOutcome::ForeignUser, "ForeignUser"),
(DirectOutcome::NotAPerson, "NotAPerson"),
(DirectOutcome::NoSuchUser, "NoSuchUser"),
(DirectOutcome::InvitePending, "InvitePending"),
(DirectOutcome::Declined, "Declined"),
(DirectOutcome::DailyLimit, "DailyLimit"),
(DirectOutcome::SendFailed, "SendFailed"),
];
for (outcome, name) in all {
assert_eq!(outcome.name(), name);
let refused = outcome != DirectOutcome::Opened && outcome != DirectOutcome::Existing;
let ids = if refused { (None, None) } else { (Some("!r:x".to_string()), Some("$e".to_string())) };
let receipt = ChatOpenDirectReceipt { request_id: "q".into(), outcome, room_id: ids.0, event_id: ids.1 };
assert_eq!(decode::<ChatOpenDirectReceipt>(&encode(&receipt).unwrap()).unwrap(), receipt, "{name}");
}
}
#[test]
fn a_receipt_with_ids_on_a_refusal_does_not_decode() {
let wire = |outcome: &str, room: Option<&str>, event: Option<&str>| rmp_serde::to_vec(&("q", outcome, room, event)).unwrap();
assert!(decode::<ChatOpenDirectReceipt>(&wire("Declined", Some("!r:x"), Some("$e"))).is_err());
assert!(decode::<ChatOpenDirectReceipt>(&wire("Declined", Some("!r:x"), None)).is_err());
assert!(decode::<ChatOpenDirectReceipt>(&wire("Declined", None, Some("$e"))).is_err());
assert!(decode::<ChatOpenDirectReceipt>(&wire("Opened", None, None)).is_err());
assert!(decode::<ChatOpenDirectReceipt>(&wire("Opened", Some("!r:x"), None)).is_err());
assert!(decode::<ChatOpenDirectReceipt>(&wire("Existing", None, Some("$e"))).is_err());
assert!(decode::<ChatOpenDirectReceipt>(&wire("Existing", Some("!r:x"), Some("$e"))).is_ok());
assert!(decode::<ChatOpenDirectReceipt>(&wire("Declined", None, None)).is_ok());
}
#[test]
fn a_receipt_error_never_echoes_what_it_read() {
let canary = "CANARY-9f3a";
let bad_outcome = rmp_serde::to_vec(&("q", canary, None::<&str>, None::<&str>)).unwrap();
let bad_ids = rmp_serde::to_vec(&(canary, "Declined", Some(canary), Some(canary))).unwrap();
for bytes in [bad_outcome, bad_ids] {
let error = format!("{:#}", decode::<ChatOpenDirectReceipt>(&bytes).unwrap_err());
assert!(!error.contains(canary), "{error}");
}
}
/// `tail_gaps` is a defaulted fourth element: named rooms round-trip as the mouth's bytes, and an empty list encodes as the three-field answer.
#[test]
fn the_pending_answer_decodes_tail_gaps_and_defaults_them_to_none() {
let gapped: ChatPending = decode(PENDING_GAPS_BYTES).unwrap();
assert_eq!(gapped.tail_gaps, vec!["#swarm:gamecult.org".to_string(), "!dm1:gamecult.org".to_string()]);
golden(PENDING_GAPS_BYTES, &gapped);
let tailed: ChatPending = decode(PENDING_TAIL_BYTES).unwrap();
assert!(tailed.tail_gaps.is_empty());
let mut empty_fourth: serde_json::Value = rmp_serde::from_slice(PENDING_TAIL_BYTES).unwrap();
empty_fourth.as_array_mut().unwrap().push(serde_json::json!([]));
assert_eq!(decode::<ChatPending>(&rmp_serde::to_vec(&empty_fourth).unwrap()).unwrap(), tailed, "an empty fourth element is the same answer");
}

View file

@ -29,7 +29,7 @@ use serde::de::DeserializeOwned;
use sha2::{Digest, Sha256};
use crate::chat_client::{
ACK, Chat, ChatAck, ChatDelivery, ChatPending, ChatPendingRequest, ChatRead, ChatRefusal, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_V1, READ, REFUSAL, SEND_RECEIPT,
ACK, Chat, ChatAck, ChatDelivery, ChatOpenDirectReceipt, ChatOpenDirectRequest, ChatPending, ChatPendingRequest, ChatRead, ChatRefusal, MouthRefused, Refusal, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_REQUEST_V1, PENDING_V2, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, PENDING_V1, READ, REFUSAL, SEND_RECEIPT,
SEND_REQUEST, decode, encode,
};
@ -92,14 +92,14 @@ impl RudpChat {
Ok(Link { transport, runtime_id })
}
/// Puts one document and, when `answer` names a kind and record key, waits for
/// Puts one document and, when `answer` names (kind, record key) pairs, waits for
/// the document the mouth answers it with. Documents of other kinds or keys
/// (a late receipt of an earlier send) are skipped.
///
/// A failed exchange drops the link: a restarted mouth silently ignores packets from a sender it has not connected, so a held link
/// would time out on every call after it. The next call opens a fresh one. Nothing is repeated on it: the caller's request id is
/// what makes a repeat of a send the same send.
fn exchange<T: Serialize>(&self, kind: &str, record_key: &str, document: &T, answer: Option<(&[&str], &str)>) -> Result<Option<(String, Vec<u8>)>> {
fn exchange<T: Serialize>(&self, kind: &str, record_key: &str, document: &T, answer: Option<&[(&str, &str)]>) -> Result<Option<(String, Vec<u8>)>> {
let result = self.exchange_on_link(kind, record_key, document, answer);
if result.is_err()
&& let Some(mut link) = self.link.borrow_mut().take()
@ -109,7 +109,7 @@ impl RudpChat {
result
}
fn exchange_on_link<T: Serialize>(&self, kind: &str, record_key: &str, document: &T, answer: Option<(&[&str], &str)>) -> Result<Option<(String, Vec<u8>)>> {
fn exchange_on_link<T: Serialize>(&self, kind: &str, record_key: &str, document: &T, answer: Option<&[(&str, &str)]>) -> Result<Option<(String, Vec<u8>)>> {
let mut slot = self.link.borrow_mut();
if slot.is_none() {
*slot = Some(self.open()?);
@ -131,7 +131,7 @@ impl RudpChat {
};
link.transport.send_schema_message(&message)?;
let deadline = Instant::now() + self.reply_timeout;
let Some((answer_kinds, answer_key)) = answer else {
let Some(answers) = answer else {
// Nothing is answered; the ack is done when the mouth has the packet.
let left = deadline.saturating_duration_since(Instant::now());
link.transport.flush_reliable(left)?;
@ -144,7 +144,7 @@ impl RudpChat {
link.transport.poll_resends()?;
if let Some(CultNetMessage::DocumentPutRaw { document, .. }) = link.transport.receive_schema_message_once()? {
let tagged = document.tags.as_deref() == Some(&[TAG.to_string()][..]);
if tagged && answer_kinds.contains(&document.schema_id.as_str()) && document.record_key == answer_key {
if tagged && answers.contains(&(document.schema_id.as_str(), document.record_key.as_str())) {
return Ok(Some((document.schema_id, document.payload)));
}
}
@ -152,16 +152,21 @@ impl RudpChat {
}
fn call<Q: Serialize, A: DeserializeOwned>(&self, kind: &str, key: &str, request: &Q, answer: (&str, &str)) -> Result<A> {
let (_, payload) = self.exchange(kind, key, request, Some((&[answer.0][..], answer.1)))?.ok_or_else(|| anyhow!("no answer payload"))?;
let (_, payload) = self.exchange(kind, key, request, Some(&[answer]))?.ok_or_else(|| anyhow!("no answer payload"))?;
decode(&payload)
}
}
impl Chat for RudpChat {
fn pending(&self, after_seq: u64) -> Result<Vec<ChatDelivery>> {
fn pending(&self, after_seq: u64) -> Result<ChatPending> {
let request = ChatPendingRequest { persona: self.persona.clone(), after_seq };
let kinds: &[&str] = &[PENDING, PENDING_V1, REFUSAL];
let (kind, payload) = self.exchange(PENDING_REQUEST, &self.persona, &request, Some((kinds, self.persona.as_str())))?.ok_or_else(|| anyhow!("no answer payload"))?;
let p = self.persona.as_str();
let answers = [(PENDING, p), (PENDING_V2, p), (PENDING_V1, p), (REFUSAL, p)];
let (mut kind, mut payload) = self.exchange(PENDING_REQUEST, p, &request, Some(&answers))?.ok_or_else(|| anyhow!("no answer payload"))?;
// A mouth that does not serve the v2 request answers `UnknownKind`: ask it the v1 way, and its deliveries carry no addressing facts.
if kind == REFUSAL && decode::<ChatRefusal>(&payload)?.refusal == Refusal::UnknownKind {
(kind, payload) = self.exchange(PENDING_REQUEST_V1, p, &request, Some(&answers))?.ok_or_else(|| anyhow!("no answer payload"))?;
}
if kind == REFUSAL {
let refusal: ChatRefusal = decode(&payload)?;
bail!("the mouth refused the pending request: {}", refusal.refusal.name());
@ -170,7 +175,7 @@ impl Chat for RudpChat {
if pending.persona != self.persona {
bail!("the mouth answered for another Persona");
}
Ok(pending.deliveries)
Ok(pending)
}
fn send(&self, request: &ChatSendRequest) -> Result<ChatSendReceipt> {
@ -181,6 +186,26 @@ impl Chat for RudpChat {
Ok(receipt)
}
fn open_direct(&self, request: &ChatOpenDirectRequest) -> Result<ChatOpenDirectReceipt> {
// A mouth that does not serve the kind answers with a refusal keyed by the persona, not by the request: heard here,
// it ends the wait, and the open is known not to have been sent.
let p = self.persona.as_str();
let answers = [(OPEN_DIRECT_RECEIPT, request.request_id.as_str()), (REFUSAL, p)];
let (kind, payload) = self.exchange(OPEN_DIRECT_REQUEST, &request.request_id, request, Some(&answers))?.ok_or_else(|| anyhow!("no answer payload"))?;
if kind == REFUSAL {
let refusal: ChatRefusal = decode(&payload)?;
if refusal.persona != self.persona || refusal.refusal != Refusal::UnknownKind {
bail!("the mouth refused the open: {}", refusal.refusal.name());
}
return Err(MouthRefused(refusal.refusal).into());
}
let receipt: ChatOpenDirectReceipt = decode(&payload)?;
if receipt.request_id != request.request_id {
bail!("the mouth answered another open");
}
Ok(receipt)
}
fn ack(&self, through_seq: u64) -> Result<()> {
let ack = ChatAck { persona: self.persona.clone(), through_seq };
self.exchange(ACK, &format!("{}:{through_seq}", self.persona), &ack, None).map(|_| ())

View file

@ -28,12 +28,12 @@ use std::path::Path;
use anyhow::{Result, anyhow, bail};
use chrono::{DateTime, Utc};
use eureka_pipeline::{RunTurn, Slug};
use eureka_pipeline::Slug;
use crate::control::{
ControlWriter, Effective, FileSource, SwarmWriter, control_path, derive, derive_swarm, load_state, load_swarm_state, swarm_path,
};
use crate::launch::{StopScope, unit};
use crate::launch::{StopScope, stop_args};
const USAGE: &str =
"usage: mind-control [--root <dir>] --instance <slug> show | check | brake hold | brake release | mind-control [--root <dir>] swarm show | brake hold | brake release";
@ -110,7 +110,7 @@ pub fn run(
let stopped = stop_units(StopScope::Mind(&instance));
match (written, stopped) {
(Ok(()), Ok(())) => {
writeln!(out, "units: stopped {} {}", unit(&instance, RunTurn::SelfRun)?, unit(&instance, RunTurn::PersonaTurn)?)?;
writeln!(out, "units: stopped {}", stop_args(StopScope::Mind(&instance))?[2..].join(" "))?;
Ok(())
}
outcome => held(outcome),
@ -151,7 +151,7 @@ fn swarm(
let written = SwarmWriter::open(&path).and_then(|mut writer| writer.set_brake(false, now, set_by));
let stopped = stop_units(StopScope::Swarm);
if written.is_ok() && stopped.is_ok() {
writeln!(out, "units: stopped every mind-self@ and mind-persona@ unit")?;
writeln!(out, "units: stopped every {}", stop_args(StopScope::Swarm)?[2..].join(" "))?;
}
held((written, stopped))
}

View file

@ -36,10 +36,10 @@ use anyhow::{Context, Result, anyhow, bail};
use chrono::{DateTime, SecondsFormat, Timelike, Utc};
use cultcache_rs::{CultCache, DatabaseEntry, SingleFileMessagePackBackingStore};
use cultnet_rs::Selection;
use eureka_pipeline::{PipelineKind, PipelineRef, ResolutionOutcome, RunOperator, RunTurn, Slug};
use eureka_pipeline::{OrgRepo, PipelineKind, PipelineRef, ResolutionOutcome, RunOperator, RunTurn, Slug};
use huginn_mind::{PipelineDocumentSummary, PipelineFacts, PipelinePageItems};
use crate::address::{Addressee, Name, addresses, project_names};
use crate::address::{Addressee, Name, is_for, project_names};
use crate::chat_client::{Chat, ChatDelivery};
use crate::control::{ControlSource, Effective, FileSource, HeldReason, SwarmEffective, read_effective, read_envelope, set_mode_world_readable};
use crate::launch::{Clock, LaunchOutcome, LaunchRequest, Launcher, Ports, open_and_launch, slot};
@ -502,15 +502,16 @@ fn newest(mind: &dyn MindPort, selection: Selection) -> Result<Option<PipelineDo
Ok(found.into_iter().next())
}
/// The chat names of every repo she stewards in force: one query on her mind.
fn project_names_of(mind: &dyn MindPort) -> Result<Vec<Name>> {
let mut names = Vec::new();
/// Every repo she stewards in force and the chat names of those repos: one query on her mind.
pub(crate) fn stewardships_of(mind: &dyn MindPort) -> Result<(Vec<Name>, Vec<OrgRepo>)> {
let (mut names, mut repos) = (Vec::new(), Vec::new());
for header in headers(mind, &in_force(of_kind(PipelineKind::Stewardship)))? {
if let PipelineFacts::Stewardship { repo, names: listed, .. } = &header.facts {
names.extend(project_names(repo, listed));
repos.push(repo.clone());
}
}
Ok(names)
Ok((names, repos))
}
/// Whether a delivery wakes `who` off the beat: the operator or another Mind of
@ -518,7 +519,7 @@ fn project_names_of(mind: &dyn MindPort) -> Result<Vec<Name>> {
fn wakes(config: &Config, who: &Addressee, delivery: &ChatDelivery) -> bool {
let from_operator = config.operators.contains(&delivery.sender);
let from_other_mind = delivery.sender != who.matrix_id() && config.personas.iter().any(|mind| mind.matrix_id == delivery.sender);
(from_operator || from_other_mind) && addresses(who, delivery)
(from_operator || from_other_mind) && is_for(who, delivery)
}
/// The mouth pages a pending answer, so fewer deliveries may come than wait.
@ -528,7 +529,7 @@ fn read_pending(chat: &dyn Chat) -> Result<Vec<ChatDelivery>> {
let mut all: Vec<ChatDelivery> = Vec::new();
let mut after = 0;
for _ in 0..PENDING_PAGES {
let page = chat.pending(after)?;
let page = chat.pending(after)?.deliveries;
let Some(top) = page.iter().map(|delivery| delivery.seq).max() else {
break;
};
@ -633,7 +634,7 @@ impl Row<'_> {
/// A member (not the operator) has a pending message that names or tags her.
fn addressed_by_member(&self, config: &Config) -> bool {
self.pending.iter().any(|delivery| !config.operators.contains(&delivery.sender) && addresses(&self.addressee, delivery))
self.pending.iter().any(|delivery| !config.operators.contains(&delivery.sender) && is_for(&self.addressee, delivery))
}
}
@ -771,8 +772,8 @@ pub fn poll(organs: &Organs, config: &Config) -> Result<Outcome> {
}
// Her projects' names address her too; read only when something is pending. A failed read keeps her last-read row.
if !row.pending.is_empty() {
match project_names_of(seat.mind) {
Ok(names) => row.addressee = roster.with_projects(names),
match stewardships_of(seat.mind) {
Ok((names, repos)) => row.addressee = roster.with_stewardships(names, repos),
Err(error) => row.note = format!("{} stewardship names unread: {error}", row.note).trim().to_string(),
}
}

View file

@ -12,6 +12,7 @@ use chrono::{Duration, TimeZone};
use eureka_pipeline::PipelineKind as K;
use huginn_mind::PipelineSelectionPage;
use tempfile::TempDir;
use crate::address::addresses;
use super::*;
use crate::control::{Brake, ControlState, SwarmState};
@ -87,15 +88,19 @@ struct FakeChat {
}
impl Chat for FakeChat {
fn pending(&self, after_seq: u64) -> Result<Vec<ChatDelivery>> {
fn pending(&self, after_seq: u64) -> Result<crate::chat_client::ChatPending> {
self.pending_calls.set(self.pending_calls.get() + 1);
Ok(self.deliveries.borrow().iter().filter(|delivery| delivery.seq > after_seq).cloned().collect())
Ok(crate::chat_client::ChatPending { persona: "eureka".into(), deliveries: self.deliveries.borrow().iter().filter(|delivery| delivery.seq > after_seq).cloned().collect(), tail: None, tail_gaps: vec![] })
}
fn send(&self, _: &crate::chat_client::ChatSendRequest) -> Result<crate::chat_client::ChatSendReceipt> {
panic!("the heartbeat sends nothing")
}
fn open_direct(&self, _: &crate::chat_client::ChatOpenDirectRequest) -> Result<crate::chat_client::ChatOpenDirectReceipt> {
panic!("the heartbeat opens nothing")
}
fn ack(&self, _: u64) -> Result<()> {
panic!("the heartbeat never acks a delivery")
}
@ -157,6 +162,8 @@ fn delivery(seq: u64, sender: &str, text: &str, mentions: &[&str]) -> ChatDelive
mentions: mentions.iter().map(|id| (*id).to_string()).collect(),
is_direct: false,
image: None,
basis: None,
form: crate::chat_client::Form::Message,
}
}
@ -634,6 +641,8 @@ fn the_heartbeat_writes_the_table_it_read_and_only_when_it_changed() {
rig.poll();
assert_eq!(rig.store.addressing_saves.get(), 2);
assert!(addresses(&table_row(&rig, "@bob:chat"), &delivery(9, "@op:chat", "BDS is down", &[])));
assert!(table_row(&rig, "@bob:chat").repos().contains(&eureka_pipeline::OrgRepo("GameCult/BadDaySimulator".into())), "the row carries the repos her names came from");
assert!(!table_row(&rig, "@ann:chat").repos().contains(&eureka_pipeline::OrgRepo("GameCult/BadDaySimulator".into())));
assert!(addresses(&table_row(&rig, "@ann:chat"), &delivery(9, "@op:chat", "Ann?", &[])) && !addresses(&table_row(&rig, "@ann:chat"), &delivery(9, "@op:chat", "BDS", &[])));
rig.poll();
assert_eq!(rig.store.addressing_saves.get(), 2, "an identical poll writes nothing");
@ -1423,7 +1432,7 @@ fn the_file_store_reads_back_the_addressing_it_wrote_to_the_view() {
let dir = TempDir::new().unwrap();
let store = FileStore::new(dir.path().join("heartbeat.cc"));
assert_eq!(store.addressing().unwrap(), None);
let table = Addressing { minds: vec![Addressee::new("@bob:chat", ["Bob"]).with_projects([Name { text: "BDS".into(), exact: true }])] };
let table = Addressing { minds: vec![Addressee::new("@bob:chat", ["Bob"]).with_stewardships([Name { text: "BDS".into(), exact: true }], [])] };
store.save_addressing(&table).unwrap();
assert_eq!(store.addressing().unwrap(), Some(table.clone()));
assert_eq!(store.load_addressing().unwrap(), Some(table));
@ -1552,3 +1561,34 @@ fn a_sleeping_mind_is_not_eligible_for_the_beat() {
assert_eq!(rig.poll(), Outcome::Polled { launched: vec![Slug("ann".into()), Slug("bob".into())] });
assert_eq!(started_turns(&rig), vec![("ann".to_string(), RunTurn::Sleep), ("bob".to_string(), RunTurn::PersonaTurn)], "the beat went to the Mind who was awake, though ann's urgency was the higher");
}
/// Matrix's word that a message is her DM, a tag or a reply wakes her off the beat from the operator; a notice or an edit never does,
/// whatever its basis, and a stranger's direct message is delivered but wakes no one.
#[test]
fn matrixs_addressing_facts_wake_her_off_the_beat_only_on_a_message_from_the_roster() {
use crate::chat_client::{Basis, Form};
let with = |seq, sender: &str, basis, form| ChatDelivery { basis, form, ..delivery(seq, sender, "no name here", &[]) };
for (basis, form, wakes) in [
(Some(Basis::Direct), Form::Message, true),
(Some(Basis::Tag), Form::Message, true),
(Some(Basis::Reply), Form::Message, true),
(Some(Basis::Room), Form::Message, false),
(None, Form::Message, false),
(Some(Basis::Direct), Form::Notice, false),
(Some(Basis::Tag), Form::Edit, false),
] {
let rig = Rig::new();
rig.turn(ANN, 10);
rig.turn(BOB, 10);
rig.store.save(&HeartbeatState { last_beat_at: rig.now(), handled_mentions: vec![] }).unwrap();
rig.say(BOB, with(1, "@op:chat", basis, form));
let launched = if wakes { vec![Slug("bob".into())] } else { vec![] };
assert_eq!(rig.poll(), Outcome::Polled { launched }, "{basis:?} {form:?}");
}
let rig = Rig::new();
rig.turn(ANN, 10);
rig.turn(BOB, 10);
rig.store.save(&HeartbeatState { last_beat_at: rig.now(), handled_mentions: vec![] }).unwrap();
rig.say(BOB, with(1, "@stranger:chat", Some(Basis::Direct), Form::Message));
assert_eq!(rig.poll(), Outcome::Polled { launched: vec![] }, "the sender gate stays: a stranger's DM is delivered and wakes no one off the beat");
}

View file

@ -124,15 +124,23 @@ fn template(turn: RunTurn) -> &'static str {
}
}
/// The unit of one turn: the template by turn, then the instance, held to the
/// leaf's grammar first so it cannot escape the name. The error names nothing
/// of the value. Start and alive both name the unit here.
pub(crate) fn unit(instance: &Slug, turn: RunTurn) -> Result<String> {
/// The stream's unit template: not a run slot, so only a brake hold names it.
const STREAM_TEMPLATE: &str = "mind-stream";
/// A template's unit for one instance, the instance held to the leaf's grammar
/// first so it cannot escape the name. The error names nothing of the value.
fn named(template: &str, instance: &Slug) -> Result<String> {
instance.validate_slug().map_err(|_| anyhow!("the instance is not a unit name"))?;
Ok(format!("{}@{}.service", template(turn), instance.0))
Ok(format!("{template}@{}.service", instance.0))
}
/// What one stop reaches: the two units of one Mind, or every Mind's.
/// The unit of one turn: the template by turn, then the instance. Start and
/// alive both name the unit here.
pub(crate) fn unit(instance: &Slug, turn: RunTurn) -> Result<String> {
named(template(turn), instance)
}
/// What one stop reaches: the three units of one Mind, or every Mind's.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StopScope<'a> {
Mind(&'a Slug),
@ -141,22 +149,25 @@ pub enum StopScope<'a> {
/// The arguments of the one synchronous stop. A Mind's: the exact unit `unit()`
/// names for each turn of this instance, the same names start and alive use,
/// with no glob (a prefix glob also matches another instance, `eureka*` matches
/// and its stream's, with no glob (a prefix glob also matches another instance, `eureka*` matches
/// `eureka-two`). The swarm's: each template's glob over the loaded units,
/// which is exactly every running Mind and exits 0 when none is loaded. No
/// `--no-block`: `systemctl stop` returns when the units are down. The
/// instance is validated by `unit()`.
/// instance is validated by `named()`.
pub(crate) fn stop_args(scope: StopScope<'_>) -> Result<Vec<String>> {
let mut args = vec!["stop".to_string(), "--".to_string()];
for turn in [RunTurn::SelfRun, RunTurn::PersonaTurn] {
args.push(match scope {
StopScope::Mind(instance) => unit(instance, turn)?,
StopScope::Swarm => format!("{}@*.service", template(turn)),
});
}
args.extend(held_templates().into_iter().map(|template| match scope {
StopScope::Mind(instance) => named(template, instance),
StopScope::Swarm => Ok(format!("{template}@*.service")),
}).collect::<Result<Vec<_>>>()?);
Ok(args)
}
/// The templates a hold stops: both run slots and the stream.
fn held_templates() -> [&'static str; 3] {
[template(RunTurn::SelfRun), template(RunTurn::PersonaTurn), STREAM_TEMPLATE]
}
impl SystemdLauncher {
/// Stops the units of one Mind, or of every Mind, and waits for them. An
/// inherent method, not on `Launcher`: only a brake hold calls it, through
@ -173,10 +184,10 @@ impl SystemdLauncher {
}
let states = match scope {
StopScope::Mind(instance) => {
let states = [RunTurn::SelfRun, RunTurn::PersonaTurn]
let states = held_templates()
.into_iter()
.map(|turn| {
let unit = unit(instance, turn)?;
.map(|template| {
let unit = named(template, instance)?;
let state = self.unit_state(&unit);
Ok(format!("{unit} is {state}"))
})
@ -1486,10 +1497,12 @@ mod tests {
launcher.start(&instance, turn).unwrap();
started.push(spy.calls.borrow().last().unwrap().last().unwrap().clone());
}
assert_eq!(args[2..], started[..], "stop names exactly the units start names, for both turns");
assert_eq!(args[2..4], started[..], "stop names exactly the units start names, for both turns");
assert_eq!(args[4], format!("mind-stream@{name}.service"), "and the stream of that instance");
assert_eq!(args.len(), 5);
assert!(args[2..].iter().all(|unit| !unit.contains(['*', '?', '['])), "a glob can match another instance or none: {args:?}");
}
assert_eq!(stop_args(StopScope::Mind(&eureka())).unwrap(), ["stop", "--", "mind-self@eureka.service", "mind-persona@eureka.service"]);
assert_eq!(stop_args(StopScope::Mind(&eureka())).unwrap(), ["stop", "--", "mind-self@eureka.service", "mind-persona@eureka.service", "mind-stream@eureka.service"]);
}
#[test]
@ -1503,7 +1516,7 @@ mod tests {
fn the_swarm_stop_is_one_stop_of_both_templates_globs() {
let (launcher, spy) = spied();
launcher.stop(StopScope::Swarm).unwrap();
assert_eq!(*spy.calls.borrow(), vec![vec!["stop", "--", "mind-self@*.service", "mind-persona@*.service"]]);
assert_eq!(*spy.calls.borrow(), vec![vec!["stop", "--", "mind-self@*.service", "mind-persona@*.service", "mind-stream@*.service"]]);
}
#[test]
@ -1535,12 +1548,12 @@ mod tests {
let (launcher, spy) = spied();
spy.success.set(false);
spy.code.set(Some(5));
spy.states.borrow_mut().extend([("mind-self@eureka.service", "inactive\n"), ("mind-persona@eureka.service", "active\n")]);
spy.states.borrow_mut().extend([("mind-self@eureka.service", "inactive\n"), ("mind-persona@eureka.service", "active\n"), ("mind-stream@eureka.service", "failed\n")]);
let shown = format!("{:#}", launcher.stop(StopScope::Mind(&eureka())).unwrap_err());
assert!(shown.contains("exit status 5"), "{shown}");
assert!(shown.contains("mind-self@eureka.service is inactive") && shown.contains("mind-persona@eureka.service is active"), "{shown}");
assert!(shown.contains("mind-self@eureka.service is inactive") && shown.contains("mind-persona@eureka.service is active") && shown.contains("mind-stream@eureka.service is failed"), "{shown}");
let asked: Vec<Vec<String>> = spy.calls.borrow().iter().skip(1).cloned().collect();
assert_eq!(asked, [["is-active", "mind-self@eureka.service"], ["is-active", "mind-persona@eureka.service"]], "one query per unit, after the one stop");
assert_eq!(asked, [["is-active", "mind-self@eureka.service"], ["is-active", "mind-persona@eureka.service"], ["is-active", "mind-stream@eureka.service"]], "one query per unit, after the one stop");
// A state word outside the three, and anything systemctl prints with it, is not carried.
spy.states.borrow_mut().clear();
*spy.stdout.borrow_mut() = "canary-output-7f3a91\n".to_string();

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -28,7 +28,7 @@ use std::collections::BTreeMap;
use anyhow::{Result, bail};
use chrono::{DateTime, Utc};
use cultnet_rs::Selection;
use eureka_pipeline::{MemoryOrigin, MemorySort, PipelineDocument, PipelineKind};
use eureka_pipeline::{MemoryOrigin, MemorySort, PipelineDocument, PipelineKind, RulingAuthority};
use huginn_mind::{PipelineDocumentView, PipelinePageItems};
use crate::persona::quoted;
@ -248,7 +248,19 @@ fn line_of(view: &PipelineDocumentView, names: &BTreeMap<&str, &str>) -> Option<
match &view.document {
PipelineDocument::Memory(_) => memory_line(view, names, false),
PipelineDocument::FollowUp(follow_up) => Some(format!("- You set yourself to: {}\n", quoted(follow_up.item.0.lines().next().unwrap_or("")))),
PipelineDocument::Ruling(ruling) => Some(format!("- Ruled: {}\n", quoted(&ruling.title.0))),
PipelineDocument::Ruling(ruling) => {
let title = quoted(&ruling.title.0);
Some(match &ruling.authority {
RulingAuthority::Steward { steward, repo, .. } => {
// A display name is a member's text: keep it to one line.
let who: String = names.get(steward.0.as_str()).copied().unwrap_or(steward.0.as_str()).chars().filter(|c| !c.is_control()).collect();
format!("- {who} ruled, as steward of {}: {title}\n", repo.0)
}
RulingAuthority::Mind => format!("- You ruled: {title}\n"),
RulingAuthority::Operator => format!("- The operator ruled: {title}\n"),
RulingAuthority::Standing | RulingAuthority::Defaulted => format!("- Ruled: {title}\n"),
})
}
_ => None,
}
}

View file

@ -402,6 +402,28 @@ fn a_follow_up_and_a_ruling_read_as_what_she_set_herself_and_what_was_ruled() {
assert!(lines.contains(&"- Ruled: \"A ruling\"\n"), "{lines:?}");
}
#[test]
fn a_ruling_names_who_decided_it() {
let mind = TestMind::seeded();
mind.committed(vec![spec("x")]);
let by = |label: &str, authority: RulingAuthority| match ruling(label, None) {
PipelineDocument::Ruling(mut ruling) => {
ruling.authority = authority;
PipelineDocument::Ruling(ruling)
}
other => other,
};
let steward = RulingAuthority::Steward { steward: "@heimdall:chat".into(), repo: eureka_pipeline::OrgRepo("GameCult/Bifrost".into()), words: "no zeros".into(), event: None };
mind.committed(vec![follow_up("fork-it", &spec_ref("x")), by("R1", steward), by("R2", RulingAuthority::Mind), by("R3", RulingAuthority::Operator)]);
let mut asking = cue(&[LIBBY], &["Later"], &[]);
asking.names = BTreeMap::from([("@heimdall:chat", "Heimdall")]);
let recalled = select(&Recording::over(&mind), &asking).unwrap();
let lines: Vec<&str> = recalled.entries().map(|entry| entry.line.as_str()).collect();
for expected in ["- Heimdall ruled, as steward of GameCult/Bifrost: \"A ruling\"\n", "- You ruled: \"A ruling\"\n", "- The operator ruled: \"A ruling\"\n"] {
assert!(lines.contains(&expected), "{expected}: {lines:?}");
}
}
#[test]
fn the_empty_notice_appears_only_when_nothing_is_remembered_or_left_out() {
let open_only = TestMind::seeded();

View file

@ -150,6 +150,8 @@ const ENV_SCRUB_VARIABLE: &str = "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB";
/// Optional, set by the unit: `off` turns that scrub off for a run whose user holds root, where it
/// protects nothing and its sandbox's no_new_privs would only stop `sudo`. Anything else keeps it on.
const SCRUB_VARIABLE: &str = "MIND_SELF_SCRUB";
/// The variable the child's `huginn` reads its `--session` from: the run's label, the id the start message names.
const SESSION_VARIABLE: &str = "HUGINN_SESSION";
/// The name the start message tells her to admit as.
pub const AGENT: &str = "mind-self-run";
/// The namespace of the session id: the run id is the name in it.
@ -227,6 +229,12 @@ fn flush_text(why: Why) -> String {
format!("{cause} {FLUSH_MESSAGE} Close each brief that is done by the closing rule; a brief whose change is not on its default branch stays open, or is superseded by your own follow-up when it waits on someone else, and the next session continues it.")
}
/// The label of a run, the last segment of its id (`<instance>:run:<label>`): what the opening receipt of every run already carries as its session
/// (`launch::run_label`), so a `huginn` admission and the run's own receipts name one session in one spelling.
fn run_label(run: &PipelineRef) -> &str {
run.id.0.rsplit(':').next().unwrap_or(&run.id.0)
}
/// The session id of a run: UUIDv5 over the run's id. Derived, never stored.
pub fn session_id(run: &PipelineRef) -> Uuid {
Uuid::new_v5(&SESSION_NAMESPACE, run.id.0.as_bytes())
@ -464,8 +472,10 @@ pub struct Kept {
}
pub trait Spawner {
/// Starts the child with `token` as its credential descriptor.
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>>;
/// Starts the child with `token` as its credential descriptor and, when `session` is given, that label
/// as its `HUGINN_SESSION`: the session its `huginn` admissions are attributed to. A child with no run
/// label (the Persona's) is given none.
fn spawn(&self, args: &[String], token: &TokenFd, session: Option<&str>) -> Result<Box<dyn Child>>;
/// The organ's one kept conversation, if any. A spawner that keeps nothing says none.
fn kept(&self) -> Option<Kept> {
@ -728,7 +738,7 @@ fn lend_and_drive(mind: &dyn MindPort, clock: &dyn Clock, lender: &dyn Lender, s
heard.resumed = Some(kept.session);
writeln!(out, "session resumed {} warm until {}", kept.session, kept.warm_until.to_rfc3339_opts(SecondsFormat::Secs, true))?;
}
let child = spawner.spawn(&claude_args(session_id(run), resume.as_ref().map(|kept| kept.transcript.as_path())), lease.token())?;
let child = spawner.spawn(&claude_args(session_id(run), resume.as_ref().map(|kept| kept.transcript.as_path())), lease.token(), Some(run_label(run)))?;
let mut inbox = SelfInbox::new(mind, (run, claims), task, (lease.plan_quota(), started_ms), (told, conversation), heard, resume.is_some());
let ran = drive(clock, child, &mut inbox, run_cap_s, out);
inbox.admit_unadmitted();
@ -815,6 +825,7 @@ impl<'a> SelfInbox<'a> {
fn reply_to(&mut self, line: &str, ok: bool) {
let Ok(Value::Object(event)) = serde_json::from_str::<Value>(line) else { return };
let text = result_text(&event);
let text = text.as_str();
let flush = std::mem::take(&mut self.flushing);
// The flush result is the session's note to its successor, in the journal: it is no reply.
if ok && !flush && !text.trim().is_empty() {
@ -913,8 +924,109 @@ fn result_head(event: &serde_json::Map<String, Value>) -> String {
head
}
fn result_text(event: &serde_json::Map<String, Value>) -> &str {
event.get("result").and_then(Value::as_str).unwrap_or("")
/// A result's words as they leave the actuator, for the journal and for her mind (the reply): redacted by `redact_url_userinfo`.
fn result_text(event: &serde_json::Map<String, Value>) -> String {
redact_url_userinfo(event.get("result").and_then(Value::as_str).unwrap_or(""))
}
/// The one redaction of URL credentials. Call it at each place a string the CLI produced enters the journal, her mind or chat: today
/// the Self result (`result_text`: journal line and Reply) and the structured output of a Persona or Sleep result
/// (`persona::structured_output`, string by string). It does not cover claude's stderr (inherited) or the CLI's transcript files.
///
/// A forge token rides in a remote URL, so every `scheme://` and every protocol-relative `//` opens an authority, read as git, curl and
/// RFC 3986 read it: it ends at the first `/`, `?`, `#`, backslash, quote, backtick, parenthesis, bracket, angle bracket or ASCII whitespace
/// (`is_authority_end`). Its userinfo is everything before the authority's LAST `@` (a raw `@` in a password still redacts), and non-ASCII
/// characters, NBSP and U+2028 included, stay inside it. The userinfo becomes `<redacted>`: `scheme://<redacted>@host`. With a colon it is
/// always redacted; without one it is a bare token on http, https, ftp, ftps, ws, wss, any `+http`/`+https` scheme (`git+https`), protocol-relative `//` and an unparseable scheme, and a plain
/// username on any other scheme (`ssh://git@host`), which stays. A `@` after the authority (`github.com/@user`, `pkg@1.2.3`) is path, not
/// userinfo, and is left alone. Scheme-less credentials are covered only in the shape `word:secret@host.tld/` at the start of an
/// ASCII-whitespace-delimited word. Not covered: a password holding `/ ? # ` a quote or ASCII whitespace (no parser reads it as userinfo),
/// scp-style `user:secret@host:path`, tokens in query strings, claude's stderr, the kept transcripts. Syntax only; no reading of meaning.
pub(crate) fn redact_url_userinfo(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find("//") {
let (before, after) = rest.split_at(at);
let body = &after[2..];
out.push_str(before);
out.push_str("//");
// The scheme is the ASCII run before `:`. Without a colon, `//` opens an authority only at a word start (`a//b@c` is not one).
let scheme = match before.strip_suffix(':') {
// Only ASCII bytes match, so the byte after the last non-match is a character boundary.
Some(head) => head[head.bytes().rposition(|c| !(c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.'))).map_or(0, |i| i + 1)..].to_ascii_lowercase(),
None if before.chars().next_back().is_none_or(|c| is_break(c) || matches!(c, '"' | '\'' | '`' | '(' | '[' | '{' | '<' | '=' | ',')) => String::new(),
None => {
rest = body;
continue;
}
};
let end = body.find(is_authority_end).unwrap_or(body.len());
let authority = &body[..end];
match authority.rfind('@') {
Some(i) if i > 0 && (authority[..i].contains(':') || carries_bare_token(&scheme)) => {
out.push_str("<redacted>");
out.push_str(&authority[i..]);
}
_ => out.push_str(authority),
}
rest = &body[end..];
}
out.push_str(rest);
redact_schemeless(&out)
}
/// Schemes whose lone userinfo is a token, not a username: web and ftp schemes, `ws`/`wss`, and `x+http`/`x+https` wrappers; `scheme` is lowercase.
fn carries_bare_token(scheme: &str) -> bool {
matches!(scheme, "" | "http" | "https" | "ftp" | "ftps" | "ws" | "wss") || scheme.ends_with("+http") || scheme.ends_with("+https")
}
/// ASCII whitespace; NBSP, U+2028 and other Unicode whitespace are not, so a secret holding them is redacted whole.
fn is_break(c: char) -> bool {
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')
}
/// The end of a URL's authority.
fn is_authority_end(c: char) -> bool {
is_break(c) || matches!(c, '/' | '?' | '#' | '\\' | '"' | '\'' | '`' | '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>')
}
/// `word:secret@host.tld/` at the start of a whitespace-delimited word that holds no `//` becomes `<redacted>@host.tld/`.
fn redact_schemeless(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut word = String::new();
let flush = |word: &mut String, out: &mut String| {
out.push_str(&redact_word(word));
word.clear();
};
for c in text.chars() {
if is_break(c) {
flush(&mut word, &mut out);
out.push(c);
} else {
word.push(c);
}
}
flush(&mut word, &mut out);
out
}
fn redact_word(word: &str) -> String {
let shaped = (|| {
if word.contains("//") {
return None;
}
let at = word.rfind('@')?;
let colon = word.find(':')?;
let (user, host) = (&word[..colon], &word[at + 1..]);
let host_name = &host[..host.find('/')?];
let user_ok = !user.is_empty() && user.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'));
let host_ok = host_name.contains('.') && host_name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'));
(colon + 1 < at && user_ok && host_ok).then_some(at)
})();
match shaped {
Some(at) => format!("<redacted>{}", &word[at..]),
None => word.to_string(),
}
}
/// `text` in consecutive pieces of at most `limit` bytes, each cut at a character boundary; their concatenation is `text`.
@ -1090,13 +1202,13 @@ impl Spawner for ClaudeSpawner {
clear_kept_dir(dir, &[KEPT_RECORD.to_string(), format!("{session}.jsonl")]).context("the conversation before it could not be removed")
}
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>> {
fn spawn(&self, args: &[String], token: &TokenFd, session: Option<&str>) -> Result<Box<dyn Child>> {
let config = FreshConfig::create(&self.scratch, self.seed.as_deref(), self.keep.clone())?;
let Some(sandbox) = &self.sandbox else { return spawn_program(Path::new("claude"), args, token, &self.unit_environment, config) };
let Some(sandbox) = &self.sandbox else { return spawn_program(Path::new("claude"), args, token, (&self.unit_environment, session), config) };
let mut wrapped = sandbox_args(sandbox, &self.scratch);
wrapped.push("claude".into());
wrapped.extend(args.iter().cloned());
spawn_program(Path::new("bwrap"), &wrapped, token, &self.unit_environment, config)
spawn_program(Path::new("bwrap"), &wrapped, token, (&self.unit_environment, session), config)
}
}
@ -1274,6 +1386,7 @@ fn copy_level(root: &Path, from: &Path, to: &Path) -> std::io::Result<()> {
/// `CLAUDE_CONFIG_DIR` are its fresh configuration directory, set by the spawn.
/// `EUREKA_INSTANCE` and `HUGINN_ENDPOINT` are the `huginn` CLI's whole identity
/// (ruling `eureka-body:ruling:cli-over-mcp-agent-surface`): a name and an address, no secret.
/// Its session, `HUGINN_SESSION`, is set by the spawn from the run, never passed through.
fn child_may_see(name: &OsStr) -> bool {
let name = name.to_string_lossy();
matches!(name.as_ref(), "PATH" | "LANG" | "TERM" | "EUREKA_INSTANCE" | "HUGINN_ENDPOINT") || name.starts_with("LC_")
@ -1295,13 +1408,17 @@ fn scrub_is_off(unit: &[(OsString, OsString)]) -> bool {
/// token. `claude` reads it to the end once at startup, so what its own children
/// inherit is an emptied pipe whose write end exists nowhere. The child's
/// environment is cleared and rebuilt from `unit`'s allow-listed variables.
fn spawn_program(program: &Path, args: &[String], token: &TokenFd, unit: &[(OsString, OsString)], config: FreshConfig) -> Result<Box<dyn Child>> {
fn spawn_program(program: &Path, args: &[String], token: &TokenFd, (unit, session): (&[(OsString, OsString)], Option<&str>), config: FreshConfig) -> Result<Box<dyn Child>> {
let inheritable = token.inheritable().context("the credential descriptor could not be placed")?;
let mut command = Command::new(program);
command.args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::inherit());
command.env_clear().envs(child_environment(unit));
command.env("HOME", &config.dir).env(CONFIG_DIR_VARIABLE, &config.dir);
command.env(DISABLE_COMPACT_VARIABLE, "1").env(TOKEN_FD_VARIABLE, inheritable.number().to_string()).env(ENV_SCRUB_VARIABLE, if scrub_is_off(unit) { "0" } else { "1" });
// The unit's own `HUGINN_SESSION` is not on the allow-list: the only value the child can hold is the one its run was given.
if let Some(session) = session {
command.env(SESSION_VARIABLE, session);
}
// Its own process group, so that a signal reaches what the child started too.
#[cfg(unix)]
std::os::unix::process::CommandExt::process_group(&mut command, 0);

View file

@ -138,6 +138,7 @@ struct Ran {
/// Every event, the lease's included.
all: Vec<Event>,
spawns: Vec<Vec<String>>,
sessions: Vec<Option<String>>,
admits: usize,
asked: Vec<LeaseRequest>,
}
@ -215,7 +216,8 @@ fn carry_kept(seen: &Seen, steps: Vec<Step>, (code, dies_on, refuses, hangs): (O
// The child's own events; the lease's are in `all`, in order with them.
let events = all.iter().filter(|event| !matches!(event, Event::Lent | Event::Spawned | Event::Abandoned | Event::Kept(_))).cloned().collect();
let spawns = fake.spawns.borrow().clone();
Ran { outcome, out: String::from_utf8(out).unwrap(), events, all, spawns, admits: seen.admits.get(), asked: lender.asked.borrow().clone() }
let sessions = fake.sessions.borrow().clone();
Ran { outcome, out: String::from_utf8(out).unwrap(), events, all, spawns, sessions, admits: seen.admits.get(), asked: lender.asked.borrow().clone() }
}
fn at_start() -> chrono::DateTime<chrono::Utc> {
@ -363,6 +365,26 @@ fn the_child_is_started_for_her_run_with_the_fixed_list_whatever_the_run_record_
}
}
#[test]
fn the_child_is_given_its_runs_label_as_huginn_session_fresh_resumed_and_in_the_next_run_only() {
// Fresh: the bare label, the spelling the run's opening receipt carries, not the id and not the derived UUID.
let a = with_run();
let fresh = carry_with(&Seen::of(&a), vec![Out(result_line())], Some(0), false);
assert_eq!(fresh.sessions, [Some("selfrun-a".to_string())]);
// Resumed: the same child, the new run's label.
let resumed = carry_kept(&Seen::of(&with_run()), vec![Out(result_line())], (Some(0), None, &[], false), Some(resume::kept_for(1800, true)));
assert_eq!(resumed.sessions, [Some("selfrun-a".to_string())]);
// The rotation's new run sets its own; nothing of the previous run is carried.
let b = TestMind::seeded();
b.committed(vec![self_run("selfrun-b", "5")]);
let next = carry_kept(&Seen::of(&b), vec![Out(result_line())], (Some(0), None, &[], false), Some(resume::kept_for(1800, true)));
assert_eq!(next.sessions, [Some("selfrun-b".to_string())]);
for ran in [&fresh, &resumed, &next] {
assert!(ran.sessions.iter().flatten().all(|label| !label.contains(':') && label.parse::<Uuid>().is_err()), "{:?}", ran.sessions);
}
assert!(!next.sessions.contains(&Some("selfrun-a".to_string())));
}
#[test]
fn the_start_message_is_the_first_thing_written_and_names_the_skill_the_instance_and_the_run() {
let ran = go(vec![Out(result_line())]);
@ -962,7 +984,7 @@ mod process {
fn start_with(program: &Path, args: &[String], token: &TokenFd) -> Box<dyn Child> {
for _ in 0..50 {
let config = FreshConfig::create(program.parent().unwrap(), None, None).unwrap();
match spawn_program(program, args, token, &unit_environment(), config) {
match spawn_program(program, args, token, (&unit_environment(), None), config) {
Ok(child) => return child,
Err(error) if format!("{error:#}").contains("busy") => std::thread::sleep(StdDuration::from_millis(100)),
Err(error) => panic!("{error:#}"),
@ -1034,7 +1056,7 @@ mod process {
let canary = "canary-argument-6b0d";
let scratch = tempfile::tempdir().unwrap();
let config = FreshConfig::create(scratch.path(), None, None).unwrap();
let error = spawn_program(Path::new("/nonexistent/claude"), &[canary.to_string()], &token_holding("tok-canary"), &[], config).err().unwrap();
let error = spawn_program(Path::new("/nonexistent/claude"), &[canary.to_string()], &token_holding("tok-canary"), (&[], None), config).err().unwrap();
assert!(!format!("{error:#}").contains(canary) && !format!("{error:#}").contains("nonexistent"), "{error:#}");
}
@ -1061,7 +1083,7 @@ mod process {
struct Spawn(PathBuf);
impl Spawner for Spawn {
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>> {
fn spawn(&self, args: &[String], token: &TokenFd, _session: Option<&str>) -> Result<Box<dyn Child>> {
Ok(Box::new(Prompt(start_with(&self.0, args, token))))
}
}
@ -1087,7 +1109,7 @@ mod process {
struct Patient(PathBuf);
impl Spawner for Patient {
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>> {
fn spawn(&self, args: &[String], token: &TokenFd, _session: Option<&str>) -> Result<Box<dyn Child>> {
Ok(Box::new(Bounded(start_with(&self.0, args, token), std::time::Instant::now())))
}
}
@ -1942,7 +1964,7 @@ mod sandbox {
unit.push(("PATH".into(), format!("{}:/usr/bin:/bin", bin.display()).into()));
unit.push((SCRATCH_VARIABLE.into(), scratch.clone().into_os_string()));
let spawner = ClaudeSpawner::in_sandbox(unit, "MIND_PERSONA_CONFIG_SEED", Some(sandbox.clone())).unwrap();
let mut child = spawner.spawn(&["--flag".to_string(), "value".to_string()], &token_holding("tok-canary")).unwrap();
let mut child = spawner.spawn(&["--flag".to_string(), "value".to_string()], &token_holding("tok-canary"), None).unwrap();
while !matches!(child.next(StdDuration::from_secs(20)), Read::Exited(_) | Read::Timeout) {}
let recorded: Vec<String> = std::fs::read_to_string(&argv).unwrap().lines().map(String::from).collect();
let mut expected = sandbox_args(&sandbox, &scratch);

View file

@ -91,7 +91,7 @@ echo left > "$CLAUDE_CONFIG_DIR/leftover"
fn run_once(program: &Path, scratch: &Path, seed: Option<&Path>) -> Vec<String> {
let config = FreshConfig::create(scratch, seed, None).unwrap();
let mut child = spawn_program(program, &[], &token_holding("tok-canary-7f3a"), &unit_environment(), config).unwrap();
let mut child = spawn_program(program, &[], &token_holding("tok-canary-7f3a"), (&unit_environment(), None), config).unwrap();
child.close_input();
let (lines, code) = drain(&mut *child);
assert_eq!(code, Some(0), "{lines:?}");
@ -167,7 +167,7 @@ fn the_child_scrubs_its_subprocesses_unless_the_unit_says_off() {
let mut unit = unit_environment();
unit.extend(value.map(|value| (SCRUB_VARIABLE.into(), value.into())));
let config = FreshConfig::create(dir.path(), None, None).unwrap();
let mut child = spawn_program(&program, &[], &token_holding("tok-canary-7f3a"), &unit, config).unwrap();
let mut child = spawn_program(&program, &[], &token_holding("tok-canary-7f3a"), (&unit, None), config).unwrap();
child.close_input();
let (lines, _) = drain(&mut *child);
field(&lines, "scrub")
@ -177,3 +177,22 @@ fn the_child_scrubs_its_subprocesses_unless_the_unit_says_off() {
assert_eq!(scrub_with(Some("OFF")), "1", "only the exact word turns it off");
assert_eq!(scrub_with(Some("off")), "0", "a unit for a user that holds root turns it off");
}
#[test]
fn the_child_holds_huginn_session_only_as_the_spawn_set_it_never_as_the_unit_had_it() {
let dir = tempfile::tempdir().unwrap();
let program = script(&dir, "echo \"session=${HUGINN_SESSION-unset}\"");
let session_of = |unit_value: Option<&str>, given: Option<&str>| {
let mut unit = unit_environment();
unit.extend(unit_value.map(|value| ("HUGINN_SESSION".into(), value.into())));
let config = FreshConfig::create(dir.path(), None, None).unwrap();
let mut child = spawn_program(&program, &[], &token_holding("tok-canary-7f3a"), (&unit, given), config).unwrap();
child.close_input();
let (lines, _) = drain(&mut *child);
field(&lines, "session")
};
assert_eq!(session_of(None, Some("mind-20261111T072944197Z")), "mind-20261111T072944197Z");
assert_eq!(session_of(None, None), "unset", "a child with no run label is given none");
assert_eq!(session_of(Some("stale-unit-value"), None), "unset", "the unit's own value is not on the allow-list");
assert_eq!(session_of(Some("stale-unit-value"), Some("mind-new")), "mind-new", "the run's label is the only value");
}

View file

@ -118,3 +118,110 @@ fn a_refused_piece_of_a_long_result_holds_back_the_pieces_behind_it() {
let ordinals: Vec<u64> = (1..=3).map(|n| mind.view(K::Reply, &format!("{INSTANCE}:reply:selfrun-a-r{n}")).unwrap_or_else(|| panic!("piece {n} lost")).admission.ordinal).collect();
assert!(ordinals.windows(2).all(|w| w[0] < w[1]), "the pieces reached the mind out of order: {ordinals:?}");
}
// A canary stands where a forge token would: no byte of it may reach the journal or her mind.
const CANARY: &str = "CANARY-NOT-A-REAL-TOKEN-0000";
#[test]
fn url_userinfo_is_redacted_where_it_is_a_secret_and_kept_where_it_is_a_username() {
let said = format!(
"a https://user:{CANARY}@host.example/r.git b https://{CANARY}@host.example/r.git c ssh://u:{CANARY}@h/x d http://{CANARY}@h e HTTPS://x:{CANARY}@h \
f https://u:p@ss{CANARY}@h/x g ssh://git@host.example/r.git h git://git@h/x i https://host.example/no-userinfo j (https://u:{CANARY}@h) k ://@x"
);
let want = "a https://<redacted>@host.example/r.git b https://<redacted>@host.example/r.git c ssh://<redacted>@h/x d http://<redacted>@h e HTTPS://<redacted>@h \
f https://<redacted>@h/x g ssh://git@host.example/r.git h git://git@h/x i https://host.example/no-userinfo j (https://<redacted>@h) k ://@x";
assert_eq!(redact_url_userinfo(&said), want);
assert!(!redact_url_userinfo(&said).contains(CANARY));
}
#[test]
fn a_credential_in_a_url_reaches_neither_the_journal_nor_the_admitted_reply() {
let mind = live_mind();
let said = format!("origin https://user:{CANARY}@host.example/r.git and https://{CANARY}@host.example/s.git");
let ran = mind_run(&mind, vec![Out(echo()), Out(saying(&said))]);
assert!(ran.outcome.is_ok());
assert!(!ran.out.contains(CANARY), "{}", ran.out);
assert!(ran.out.contains("https://<redacted>@host.example/r.git"), "{}", ran.out);
let text = text_of(&mind, 1);
assert!(!text.contains(CANARY), "{text}");
assert_eq!(text, "origin https://<redacted>@host.example/r.git and https://<redacted>@host.example/s.git");
}
#[test]
fn a_credential_split_by_the_reply_limit_is_redacted_before_the_cut() {
let mind = live_mind();
let said = format!("{}https://u:{CANARY}@h/x{}", "a".repeat(REPLY_TEXT_LIMIT - 25), "b".repeat(100));
mind_run(&mind, vec![Out(echo()), Out(saying(&said))]).outcome.unwrap();
let first = reply_of(&mind, 1).expect("the long result was admitted in pieces");
assert!(reply_of(&mind, 2).is_some(), "the credential's place straddles the cut");
assert!(!first.text.0.contains("CANARY") && !reply_of(&mind, 2).unwrap().text.0.contains("CANARY"));
}
#[test]
fn a_secret_is_redacted_whole_by_the_authority_rule_and_commonplace_urls_are_left_alone() {
let r = redact_url_userinfo;
// Raw @, percent-encoding and non-ASCII (NBSP, U+2028) stay inside userinfo.
for secret in ["pa@ssT", "pa%40ssT", "pa\u{a0}ssT", "pa\u{2028}ssT", "pa:ssT", "\u{e9}\u{65e5}ssT"] {
let out = r(&format!("x https://u:{secret}@h/x"));
assert_eq!(out, "x https://<redacted>@h/x", "{secret:?}");
}
// The authority ends at / ? # \\ quotes, brackets and ASCII whitespace: no parser reads those as userinfo, so what follows is path.
assert_eq!(r("https://u:pa/ssT@h/x"), "https://u:pa/ssT@h/x");
assert_eq!(r("https://u:pa ssT@h/x"), "https://u:pa ssT@h/x");
assert_eq!(r("https://u:pa?ssT@h"), "https://u:pa?ssT@h");
// Ordinary URLs with @ in the path are byte-identical.
for same in [
"https://github.com/@user/repo",
"https://medium.com/@name/post-1",
"https://registry.npmjs.org/@scope/pkg",
"https://h/pkg@1.2.3",
"a//b@c",
"see //cdn.h/@x/y and https://h/?mail=a@b.c#f@g",
"https://h:8080/p git@github.com:org/r.git ssh://git@host/r.git ssh://git@h:22/r git://git@h/x",
] {
assert_eq!(r(same), same);
}
// Compact JSON: only the credentialed URL changes.
let json = format!(r#"{{"a":"https://u:{CANARY}@h/x","b":"https://h2/@v","c":"https://h3/p@1"}}"#);
assert_eq!(r(&json), r#"{"a":"https://<redacted>@h/x","b":"https://h2/@v","c":"https://h3/p@1"}"#);
assert_eq!(r("a/https://u:T@h/x,https://v:T@h2"), "a/https://<redacted>@h/x,https://<redacted>@h2");
assert_eq!(r("HTTPS://TOKEN@h"), "HTTPS://<redacted>@h");
assert_eq!(r("[l](https://T@h/x) <https://T@h> `https://u:T@h`"), "[l](https://<redacted>@h/x) <https://<redacted>@h> `https://<redacted>@h`");
assert_eq!(r("a //u:T@host/x b //T@host c \"//T@h\" (//T@h)"), "a //<redacted>@host/x b //<redacted>@host c \"//<redacted>@h\" (//<redacted>@h)");
// Token-bearing schemes beyond http: +http/+https wrappers and ws/wss redact a bare token; git+ssh and ssh keep their username.
assert_eq!(r("git+https://T@github.com/a/b GIT+HTTPS://T@h wss://T@h/ ws://T@h/"), "git+https://<redacted>@github.com/a/b GIT+HTTPS://<redacted>@h wss://<redacted>@h/ ws://<redacted>@h/");
for same in ["git+ssh://git@host/x", "ssh://git@host/x"] {
assert_eq!(r(same), same);
}
// Scheme-less: only word:secret@host.tld/ . Each guard of the shape has its own case.
assert_eq!(r("user:T@host.tld/path"), "<redacted>@host.tld/path");
for same in ["a:b@c", "a:b@host/x", "a:b@ho_st.tld/x", "u$er:T@host.tld/x", "a:@host.tld/x", "mail me@x.org/ and :T@host.tld/x"] {
assert_eq!(r(same), same);
}
}
#[test]
fn multibyte_text_never_panics_the_redaction() {
let r = redact_url_userinfo;
for case in ["see \u{201c}https://h/x", "\u{2192}https://h/x", "docs\u{ff1a}https://h/x", "\u{e9}://x", "\u{65e5}\u{672c}://u:T@h", "https://x/\u{e9}://", "\u{e9}//u:T@h", "\u{e9}:u:T@h.tld/"] {
let _ = r(case);
}
assert_eq!(r("\u{65e5}\u{672c}://u:T@h"), "\u{65e5}\u{672c}://<redacted>@h");
assert_eq!(r("\u{2192}https://u:T@h/x"), "\u{2192}https://<redacted>@h/x");
let seed = "\u{201c}\u{2192}\u{ff1a}\u{e9}\u{65e5}\u{a0}https://u:T@h/x//y:z@a.b/ ://@ //";
let chars: Vec<char> = seed.chars().collect();
for start in 0..=chars.len() {
for end in start..=chars.len() {
let _ = r(&chars[start..end].iter().collect::<String>());
}
}
}
#[test]
fn persona_and_sleep_structured_output_is_redacted_string_by_string() {
let line = json!({"type": "result", "structured_output": {"say": format!("https://u:{CANARY}@h/x,"), "n": 3, "list": [format!("https://{CANARY}@h")]}}).to_string();
let text = crate::persona::structured_output(&line).unwrap();
assert!(!text.contains(CANARY), "{text}");
let back: Value = serde_json::from_str(&text).unwrap();
assert_eq!(back, json!({"say": "https://<redacted>@h/x,", "n": 3, "list": ["https://<redacted>@h"]}));
}

View file

@ -15,7 +15,7 @@ fn previous() -> Uuid {
Uuid::parse_str(PREVIOUS).unwrap()
}
fn kept_for(warm_for_s: i64, same_seed: bool) -> Kept {
pub(super) fn kept_for(warm_for_s: i64, same_seed: bool) -> Kept {
Kept { session: previous(), transcript: PathBuf::from(format!("/state/session/{PREVIOUS}.jsonl")), warm_until: at_start() + ChronoDuration::seconds(warm_for_s), written_at: at_start(), same_seed }
}
@ -321,8 +321,8 @@ impl Spawner for FilesThenDies {
self.files.keep(keep)
}
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>> {
self.dies.spawn(args, token)
fn spawn(&self, args: &[String], token: &TokenFd, session: Option<&str>) -> Result<Box<dyn Child>> {
self.dies.spawn(args, token, session)
}
}

View file

@ -13,7 +13,7 @@
//! and never starts a run. Delivery to a Persona is another organ's.
use std::collections::BTreeMap;
use std::net::IpAddr;
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
@ -25,7 +25,9 @@ use eureka_pipeline::{
use eureka_state::HuginnClient;
use ghostlight_persona_projection::{ProjectorPrompt, build_projector_prompt};
use huginn_mind::{HuginnMindResponse, PipelineAdmissionOutcome, PipelineDocumentView, PipelinePageItems};
use model_connector::{ConnectorClient, InputItem, ProviderRequest, ReasoningEffort, StopReason, TransportDisposition, TransportEventPayload, TransportInvocation, TransportOutcome};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use crate::control::Effective;
use crate::persona::{Responsibilities, outcome_words, quoted, recent_runs, render_responsibilities, repo_notice_line};
@ -95,25 +97,31 @@ impl Sampling {
const STREAM: Self = Self { temperature_permille: TEMPERATURE_PERMILLE, min_p_permille: MIN_P_PERMILLE, max_tokens: MAX_TOKENS };
}
/// The two models of a tick. Faults are errors: the tick ends, the process does not.
pub trait StreamModel {
/// The Projector: one chat prompt in, one projection out.
fn project(&self, prompt: &str) -> Result<String>;
/// The continuer: `n` raw continuations of `prompt`, no chat template.
/// The Projector: state to narrative. Its one owner is `KitProjector`, her own caller on the connector. A fault is an error:
/// the tick ends, the process does not.
pub trait Projector {
/// The model id every projection of this Projector names; the thought records it, so it is the model that ran.
fn model(&self) -> &str;
/// One turn: `prompt` is the turn's one user text and `request_id` is its identity on the connector.
fn project(&self, request_id: &str, prompt: &str) -> Result<String>;
}
/// The continuer: raw continuation of a frame. Its one owner is `OllamaContinuer`, the base model on this host.
pub trait Continuer {
/// `n` raw continuations of `prompt`, no chat template.
fn continue_raw(&self, prompt: &str, sampling: &Sampling, n: usize) -> Result<Vec<String>>;
}
/// Ollama on this host. Loopback only, as Ghostlight's local binding is.
pub struct OllamaStream {
/// The base model on Ollama on this host. Loopback only, as Ghostlight's local binding is.
pub struct OllamaContinuer {
base: String,
projector_model: String,
base_model: String,
agent: ureq::Agent,
}
impl OllamaStream {
impl OllamaContinuer {
/// `endpoint` is `http://<loopback host>:<port>`; any other address is refused here.
pub fn open(endpoint: &str, projector_model: &str, base_model: &str) -> Result<Self> {
pub fn open(endpoint: &str, base_model: &str) -> Result<Self> {
// Parsed as the HTTP client parses it, so the host checked is the host connected to.
let uri: ureq::http::Uri = endpoint.parse().map_err(|_| anyhow!("the stream endpoint is not a URL"))?;
let authority = uri.authority().filter(|_| uri.scheme_str() == Some("http")).ok_or_else(|| anyhow!("the stream endpoint must be http://<loopback>:<port>"))?;
@ -137,7 +145,7 @@ impl OllamaStream {
.max_redirects(0)
.build()
.into();
Ok(Self { base: format!("http://{authority}"), projector_model: projector_model.to_owned(), base_model: base_model.to_owned(), agent })
Ok(Self { base: format!("http://{authority}"), base_model: base_model.to_owned(), agent })
}
fn post(&self, path: &str, body: &Value) -> Result<Value> {
@ -152,12 +160,7 @@ impl OllamaStream {
}
}
impl StreamModel for OllamaStream {
fn project(&self, prompt: &str) -> Result<String> {
let answer = self.post("/api/chat", &json!({ "model": self.projector_model, "stream": false, "messages": [{ "role": "user", "content": prompt }] }))?;
answer["message"]["content"].as_str().map(str::to_owned).ok_or_else(|| anyhow!("the Projector's answer has no message content"))
}
impl Continuer for OllamaContinuer {
fn continue_raw(&self, prompt: &str, sampling: &Sampling, n: usize) -> Result<Vec<String>> {
let body = json!({
"model": self.base_model,
@ -178,6 +181,82 @@ impl StreamModel for OllamaStream {
}
}
// ---------------------------------------------------------------------------------------------
// The Projector: her own caller on the connector.
/// What the connector's request asks of the model beside the user text. It names no membrane text: that is Ghostlight's.
const PROJECTOR_INSTRUCTIONS: &str = "Write only the text the request asks for.";
/// Room for the model's adaptive thinking; the projection itself is at most `PROJECTOR_WORDS` words.
const PROJECTOR_MAX_OUTPUT_TOKENS: u32 = 1024;
/// The sealed frame the client will carry: the prompt out and the projection back fit with wide room.
const CONNECTOR_FRAME_BYTES: usize = 256 * 1024;
/// How long an invocation stays admissible at the connector.
const INVOCATION_LIFE_MS: u64 = 60_000;
/// The identity of one projection on the connector. A live tick's is derived from the instance and the tick label alone; a
/// dry run's also carries the moment, because the kit's replay store answers a repeated id byte-identically.
fn request_id(instance: &Slug, label: &str, mode: Mode, now: DateTime<Utc>) -> String {
match mode {
Mode::Live => format!("{AGENT}:{}:{label}", instance.0),
Mode::DryRun => format!("{AGENT}:{}:{label}:dry{}", instance.0, now.timestamp_millis()),
}
}
/// The connector key: the credential file in systemd's credentials directory, read once and trimmed. Its error names the
/// credential and never a byte of the file.
pub fn connector_key(credentials: &std::path::Path) -> Result<String> {
let key = std::fs::read_to_string(credentials.join("connector-key")).map_err(|_| anyhow!("the connector-key credential could not be read"))?;
Ok(key.trim().to_owned())
}
/// The Projector as one tool-less connector turn by `caller` (her instance) on `model`. Loopback only, as the kit is.
/// Each projection is its own conversation: nothing is carried from one tick to the next.
pub struct KitProjector {
client: ConnectorClient,
caller: String,
model: String,
}
impl KitProjector {
/// `endpoint` is the kit's loopback address; `connection_key` is the caller's key, read by the binary and never printed.
pub fn open(endpoint: SocketAddr, connection_key: String, caller: &str, model: &str, timeout: Duration) -> Result<Self> {
if !endpoint.ip().is_loopback() {
bail!("the connector endpoint is not a loopback address");
}
let client = ConnectorClient::new(endpoint, connection_key, CONNECTOR_FRAME_BYTES, Some(timeout)).map_err(|_| anyhow!("the connector client could not be configured"))?;
Ok(Self { client, caller: caller.to_owned(), model: model.to_owned() })
}
}
impl Projector for KitProjector {
fn model(&self) -> &str {
&self.model
}
fn project(&self, request_id: &str, prompt: &str) -> Result<String> {
let mut request = ProviderRequest::new(request_id, request_id, &self.model, PROJECTOR_INSTRUCTIONS, PROJECTOR_MAX_OUTPUT_TOKENS);
request.input = vec![InputItem::UserText { text: prompt.to_owned() }];
request.reasoning_effort = Some(ReasoningEffort::Low);
let expires = u64::try_from(Utc::now().timestamp_millis()).unwrap_or_default() + INVOCATION_LIFE_MS;
let invocation = TransportInvocation::new(&self.caller, expires, Sha256::digest(prompt.as_bytes()).into(), request).map_err(|_| anyhow!("the Projector's request is not a valid connector request"))?;
let result = self.client.execute(&invocation).map_err(|fault| anyhow!("the connector did not answer the Projector: {fault}"))?;
match result.disposition {
TransportDisposition::Refused(reason) => bail!("the connector refused the Projector: {reason:?}"),
TransportDisposition::Transported { events, receipt } => match receipt.outcome {
TransportOutcome::Completed { stop_reason: StopReason::EndTurn, .. } => Ok(events
.into_iter()
.filter_map(|event| match event.payload {
TransportEventPayload::TextDelta { text } => Some(text),
_ => None,
})
.collect()),
TransportOutcome::Completed { stop_reason, .. } => bail!("the Projector's turn ended early: {stop_reason:?}"),
TransportOutcome::Failed { kind, .. } => bail!("the provider failed the Projector: {kind:?}"),
},
}
}
}
// ---------------------------------------------------------------------------------------------
// Her persona state, read and never put.
@ -526,8 +605,8 @@ pub enum Mode {
pub struct Organs<'a> {
pub mind: &'a dyn MindPort,
pub persona: &'a dyn PersonaSource,
pub model: &'a dyn StreamModel,
pub projector_model: &'a str,
pub projector: &'a dyn Projector,
pub continuer: &'a dyn Continuer,
pub base_model: &'a str,
}
@ -586,8 +665,9 @@ pub fn tick(organs: &Organs, brake: &dyn Fn() -> Effective, mode: Mode, now: Dat
let persona = organs.persona.persona()?;
let seed = build_seed(mind, &persona, since, now, &responsibilities, fresh)?;
let label = format!("t{}", now.timestamp());
let began = std::time::Instant::now();
let projection = bounded(&organs.model.project(&seed.projector_prompt())?, PARA_MAX_BYTES);
let projection = bounded(&organs.projector.project(&request_id(mind.instance(), &label, mode, now), &seed.projector_prompt())?, PARA_MAX_BYTES);
let projector_ms = u32::try_from(began.elapsed().as_millis()).unwrap_or(u32::MAX);
if projection.is_empty() {
return Ok(Outcome::Blank);
@ -595,7 +675,7 @@ pub fn tick(organs: &Organs, brake: &dyn Fn() -> Effective, mode: Mode, now: Dat
let frame = continuer_frame(text_of(&persona.state, &["publicName"]), &now.format(FRAME_DATE_FORMAT).to_string(), &projection);
let began = std::time::Instant::now();
let candidates: Vec<String> = organs
.model
.continuer
.continue_raw(&frame, &Sampling::STREAM, CONTINUATIONS)?
.iter()
.map(|text| bounded(text, LINE_MAX_BYTES))
@ -610,14 +690,14 @@ pub fn tick(organs: &Organs, brake: &dyn Fn() -> Effective, mode: Mode, now: Dat
let chosen = least_near(&nearest_permille);
let thought = PipelineThought {
instance: mind.instance().clone(),
label: Label::from(format!("t{}", now.timestamp()).as_str()),
label: Label::from(label.as_str()),
text: Para::from(candidates[chosen].as_str()),
reads: seed.reads.clone(),
persona_receipt: Short(persona.receipt_id.clone()),
identity: Line::from(seed.identity.as_str()),
context: Para::from(seed.context.as_str()),
stimulus: Para::from(seed.stimulus.as_str()),
projector_model: Short(organs.projector_model.to_owned()),
projector_model: Short(organs.projector.model().to_owned()),
projection: Para::from(projection.as_str()),
base_model: Short(organs.base_model.to_owned()),
temperature_permille: Sampling::STREAM.temperature_permille,
@ -633,7 +713,6 @@ pub fn tick(organs: &Organs, brake: &dyn Fn() -> Effective, mode: Mode, now: Dat
if mode == Mode::DryRun {
return Ok(Outcome::Thought { thought: Box::new(thought), admitted: false });
}
let label = thought.label.0.clone();
match mind.admit(AGENT, &format!("stream-{label}"), vec![PipelineDocument::Thought(thought.clone())])? {
PipelineAdmissionOutcome::Committed { .. } => Ok(Outcome::Thought { thought: Box::new(thought), admitted: true }),
other => Err(anyhow!("the mind did not admit the thought: {other:?}")),

View file

@ -58,7 +58,7 @@ struct Fake {
projection: String,
candidates: Vec<String>,
fault: bool,
projected: RefCell<Vec<String>>,
projected: RefCell<Vec<(String, String)>>,
continued: RefCell<Vec<(String, Sampling, usize)>>,
}
@ -72,12 +72,18 @@ impl Fake {
}
}
impl StreamModel for Fake {
fn project(&self, prompt: &str) -> Result<String> {
self.projected.borrow_mut().push(prompt.into());
if self.fault { Err(anyhow!("the model host is down")) } else { Ok(self.projection.clone()) }
impl Projector for Fake {
fn model(&self) -> &str {
"projector:test"
}
fn project(&self, request_id: &str, prompt: &str) -> Result<String> {
self.projected.borrow_mut().push((request_id.into(), prompt.into()));
if self.fault { Err(anyhow!("the connector is down")) } else { Ok(self.projection.clone()) }
}
}
impl Continuer for Fake {
fn continue_raw(&self, prompt: &str, sampling: &Sampling, n: usize) -> Result<Vec<String>> {
self.continued.borrow_mut().push((prompt.into(), *sampling, n));
Ok(self.candidates.iter().take(n).cloned().collect())
@ -200,7 +206,7 @@ fn mind_with_thought(thought_at: i64) -> TestMind {
}
fn organs<'a>(mind: &'a dyn MindPort, persona: &'a Persona, model: &'a Fake) -> Organs<'a> {
Organs { mind, persona, model, projector_model: "projector:test", base_model: "base:test" }
Organs { mind, persona, projector: model, continuer: model, base_model: "base:test" }
}
fn thoughts(mind: &TestMind) -> Vec<String> {
@ -414,8 +420,8 @@ fn frame_holds_projection_verbatim() {
assert!(!prompt.contains(foreign), "{foreign} reached the continuer");
}
let projected = model.projected.borrow();
assert!(projected[0].contains("private Projector") && projected[0].contains(STREAM_GUIDANCE), "the Projector prompt is Ghostlight's builder over her seed");
assert!(projected[0].contains("Libby holds the sounds"));
assert!(projected[0].1.contains("private Projector") && projected[0].1.contains(STREAM_GUIDANCE), "the Projector prompt is Ghostlight's builder over her seed");
assert!(projected[0].1.contains("Libby holds the sounds"));
}
#[test]
@ -465,7 +471,202 @@ fn stream_admits_only_thoughts() {
}
// ---------------------------------------------------------------------------------------------
// The model port against a fake HTTP server.
// The Projector against a real kit server.
use model_connector::{CallerAdmission, ProviderBackend, ProviderCall, ServeLimits, StopReason, TransportEvent, TransportResult, Usage, serve as serve_kit};
const KIT_KEY: &str = "stream-projector-test-key";
const KIT_MODEL: &str = "claude-haiku-5-5";
/// Answers every call as scripted and records the caller and the request the provider would have been shown.
struct Scripted {
outcome: TransportOutcome,
words: Vec<&'static str>,
seen: Mutex<Vec<(String, ProviderRequest)>>,
}
impl Scripted {
fn ending(stop_reason: StopReason, words: &[&'static str]) -> Arc<Self> {
let usage = Usage { input_tokens: 1, cache_read_tokens: 0, cache_write_tokens: 0, output_tokens: 1, reasoning_tokens: None };
Arc::new(Self { outcome: TransportOutcome::Completed { provider_response_id: Some("msg-1".into()), stop_reason, usage }, words: words.to_vec(), seen: Mutex::default() })
}
}
impl ProviderBackend for Scripted {
fn ready(&self) -> Result<(), &'static str> {
Ok(())
}
fn provider(&self) -> &str {
"anthropic-messages"
}
fn execute(&self, call: &ProviderCall) -> TransportResult {
self.seen.lock().unwrap().push((call.caller_runtime_id().to_owned(), call.request().clone()));
let events = self.words.iter().enumerate().map(|(at, text)| TransportEvent { sequence: at as u64, payload: TransportEventPayload::TextDelta { text: (*text).into() } }).collect();
call.transported(events, self.outcome.clone())
}
}
/// A running kit whose caller row for `instance` allows `allowed`, and a Projector opened on it.
fn kit(backend: Arc<Scripted>, allowed: &str) -> (KitProjector, tempfile::TempDir) {
let root = tempfile::tempdir().unwrap();
let store = root.path().join("replay.cc");
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let endpoint = listener.local_addr().unwrap();
let admission = CallerAdmission::new(INSTANCE, KIT_KEY, 1, [allowed.to_owned()], 2, 64 * 1024, 4096).unwrap();
let limits = ServeLimits { max_frame_bytes: 512 * 1024, max_connections: 8, socket_timeout_ms: 5000, max_expiry_skew_ms: 300_000 };
std::thread::spawn(move || serve_kit(listener, limits, vec![admission], &store, backend));
(KitProjector::open(endpoint, KIT_KEY.into(), INSTANCE, KIT_MODEL, Duration::from_secs(5)).unwrap(), root)
}
#[test]
fn projector_is_one_kit_turn() {
let backend = Scripted::ending(StopReason::EndTurn, &["I turn ", "it over."]);
let (projector, _root) = kit(Arc::clone(&backend), KIT_MODEL);
let id = "mind-stream:eureka:t100";
assert_eq!(projector.project(id, "The projector prompt.").unwrap(), "I turn it over.");
let seen = backend.seen.lock().unwrap();
let (caller, request) = &seen[0];
assert_eq!(caller, INSTANCE, "her instance is the caller");
assert_eq!((request.request_id.as_str(), request.conversation_id.as_str(), request.model.as_str()), (id, id, KIT_MODEL));
assert_eq!(request.input, [InputItem::UserText { text: "The projector prompt.".into() }]);
assert_eq!(request.instructions, PROJECTOR_INSTRUCTIONS);
assert_eq!(request.reasoning_effort, Some(ReasoningEffort::Low));
assert!(request.tools.is_empty() && request.output_schema_json.is_none());
assert_eq!(request.max_output_tokens, PROJECTOR_MAX_OUTPUT_TOKENS);
}
#[test]
fn each_projection_is_its_own_conversation() {
let backend = Scripted::ending(StopReason::EndTurn, &["One."]);
let (projector, _root) = kit(Arc::clone(&backend), KIT_MODEL);
projector.project("mind-stream:eureka:t100", "first").unwrap();
projector.project("mind-stream:eureka:t200", "second").unwrap();
let seen = backend.seen.lock().unwrap();
assert_ne!(seen[0].1.request_id, seen[1].1.request_id);
assert_ne!(seen[0].1.conversation_id, seen[1].1.conversation_id);
assert_eq!(seen[1].1.input, [InputItem::UserText { text: "second".into() }], "no transcript is carried");
}
#[test]
fn a_kit_refusal_is_a_tick_fault() {
let backend = Scripted::ending(StopReason::EndTurn, &["Never said."]);
let (projector, _root) = kit(Arc::clone(&backend), "some-other-model");
let mind = mind_with_thought(1000);
let continuer = Fake::saying("unused", &["unused"]);
let persona = persona();
let wrap = Wrap::over(&mind, &[]);
let organs = Organs { mind: &wrap, persona: &persona, projector: &projector, continuer: &continuer, base_model: "base:test" };
let fault = format!("{:#}", tick(&organs, &|| released(), Mode::Live, t(1000 + STREAM_IDLE_GAP_S)).unwrap_err());
assert!(fault.contains("ModelNotAllowed"), "{fault}");
assert!(backend.seen.lock().unwrap().is_empty(), "the provider was never reached");
assert_eq!(continuer.calls(), 0, "the continuer is not called");
assert!(wrap.admitted.borrow().is_empty());
}
#[test]
fn a_thought_names_the_model_the_kit_ran() {
let backend = Scripted::ending(StopReason::EndTurn, &["I turn it over."]);
let (projector, _root) = kit(Arc::clone(&backend), KIT_MODEL);
let mind = mind_with_thought(1000);
let continuer = Fake::saying("unused", &["A drift"]);
let persona = persona();
let wrap = Wrap::over(&mind, &[0.1]);
let organs = Organs { mind: &wrap, persona: &persona, projector: &projector, continuer: &continuer, base_model: "base:test" };
let Outcome::Thought { thought, .. } = tick(&organs, &|| released(), Mode::Live, t(1000 + STREAM_IDLE_GAP_S)).unwrap() else { panic!("a thought") };
let ran = backend.seen.lock().unwrap()[0].1.model.clone();
assert_eq!(ran, KIT_MODEL);
assert_eq!(thought.projector_model.0, ran, "the recorded model is the one the kit was asked for");
}
#[test]
fn the_instructions_restate_no_membrane_text() {
let membrane = build_projector_prompt(&ProjectorPrompt { identity: "", typed_context: "", visible_stimulus: "", domain_guidance: STREAM_GUIDANCE, word_budget: PROJECTOR_WORDS }).to_lowercase();
let words: Vec<&str> = PROJECTOR_INSTRUCTIONS.split_whitespace().collect();
assert!(!PROJECTOR_INSTRUCTIONS.contains('\n') && words.len() <= 20, "one short line");
for run in words.windows(4) {
let run = run.join(" ").to_lowercase();
assert!(!membrane.contains(&run), "Ghostlight's membrane text reached the instructions: {run}");
}
}
#[test]
fn the_connector_key_is_read_trimmed_and_its_absence_leaks_nothing() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("connector-key"), "canary-key-7f3a91\n").unwrap();
assert_eq!(connector_key(dir.path()).unwrap(), "canary-key-7f3a91");
for (written, read) in [("canary-key-7f3a91\r\n", "canary-key-7f3a91"), ("canary-key-7f3a91\n\n\n", "canary-key-7f3a91"), (" \t\ncanary-key-7f3a91 \t\r\n\n", "canary-key-7f3a91"), ("canary-key-7f3a91", "canary-key-7f3a91")] {
std::fs::write(dir.path().join("connector-key"), written).unwrap();
assert_eq!(connector_key(dir.path()).unwrap(), read, "{written:?}");
}
let empty = tempfile::tempdir().unwrap();
let fault = format!("{:#}", connector_key(empty.path()).unwrap_err());
assert!(fault.contains("connector-key") && !fault.contains(empty.path().to_str().unwrap()), "{fault}");
let bad = tempfile::tempdir().unwrap();
std::fs::write(bad.path().join("connector-key"), b"canary-key-7f3a91\xff\xfe").unwrap();
let fault = format!("{:#}", connector_key(bad.path()).unwrap_err());
assert!(!fault.contains("canary"), "no byte of the key reaches an error: {fault}");
}
#[test]
fn no_open_error_carries_any_part_of_the_key() {
let key = "zq7Kx-9wVtm3Pd";
// Every way open() can fail with the key in hand: a non-loopback endpoint and a configuration the client refuses.
for endpoint in ["10.77.0.1:4103", "127.0.0.1:0", "0.0.0.0:4103"] {
let fault = format!("{:#}", KitProjector::open(endpoint.parse().unwrap(), key.into(), INSTANCE, KIT_MODEL, Duration::from_secs(1)).err().unwrap());
for window in key.as_bytes().windows(4) {
assert!(!fault.contains(std::str::from_utf8(window).unwrap()), "{endpoint}: part of the key reached the error: {fault}");
}
}
}
#[test]
fn a_projection_cut_short_is_a_fault() {
for stop in [StopReason::MaxOutputTokens, StopReason::Refusal, StopReason::ToolUse, StopReason::ContextWindow] {
let (projector, _root) = kit(Scripted::ending(stop, &["Half a thou"]), KIT_MODEL);
let fault = format!("{:#}", projector.project("mind-stream:eureka:t1", "p").unwrap_err());
assert!(fault.contains("ended early") && !fault.contains("Half a thou"), "{stop:?}: {fault}");
}
let failed = Arc::new(Scripted { outcome: TransportOutcome::Failed { kind: model_connector::FailureKind::RateLimited, message: "slow down".into(), usage: None }, words: vec![], seen: Mutex::default() });
let (projector, _root) = kit(failed, KIT_MODEL);
assert!(format!("{:#}", projector.project("mind-stream:eureka:t1", "p").unwrap_err()).contains("RateLimited"));
}
#[test]
fn non_loopback_connector_refused() {
for refused in ["10.77.0.1:4103", "192.0.2.1:4103", "[2001:db8::1]:4103"] {
assert!(KitProjector::open(refused.parse().unwrap(), KIT_KEY.into(), INSTANCE, KIT_MODEL, Duration::from_secs(1)).is_err(), "{refused} must be refused");
}
for opened in ["127.0.0.1:4103", "[::1]:4103"] {
assert!(KitProjector::open(opened.parse().unwrap(), KIT_KEY.into(), INSTANCE, KIT_MODEL, Duration::from_secs(1)).is_ok(), "{opened} is loopback");
}
}
#[test]
fn request_ids_name_the_tick_and_a_dry_run_never_takes_the_live_one() {
let mind = mind_with_thought(1000);
let persona = persona();
let at = t(1000 + STREAM_IDLE_GAP_S);
let run = |mode, now: DateTime<Utc>| {
let model = Fake::saying("I turn it over.", &["A drift"]);
let wrap = Wrap::over(&mind, &[0.1]);
tick(&organs(&wrap, &persona, &model), &|| released(), mode, now).unwrap();
model.projected.into_inner().remove(0).0
};
let live = run(Mode::Live, at);
assert_eq!(live, format!("mind-stream:{INSTANCE}:t{}", at.timestamp()));
let dry = run(Mode::DryRun, at);
let later_dry = run(Mode::DryRun, at + Span::milliseconds(5));
assert_ne!(dry, live, "the kit's replay store would answer the live id byte-identically");
assert!(dry.starts_with(&format!("{live}:dry")), "{dry}");
assert_ne!(dry, later_dry, "two dry runs of one label do not collide");
let next = run(Mode::Live, at + Span::seconds(STREAM_IDLE_GAP_S));
assert_ne!(next, live, "each tick is its own conversation");
}
// ---------------------------------------------------------------------------------------------
// The continuer against a fake HTTP server.
type Seen = Arc<Mutex<Vec<(String, Value)>>>;
@ -503,7 +704,7 @@ fn serve(answers: Vec<String>) -> (String, Seen) {
#[test]
fn raw_generate_is_raw() {
let (endpoint, seen) = serve(vec![json!({ "response": "one" }).to_string(), json!({ "response": "two" }).to_string()]);
let model = OllamaStream::open(&endpoint, "projector:x", "base:y").unwrap();
let model = OllamaContinuer::open(&endpoint, "base:y").unwrap();
let out = model.continue_raw("The frame\n\n", &Sampling::STREAM, 2).unwrap();
assert_eq!(out, ["one", "two"]);
let seen = seen.lock().unwrap();
@ -518,16 +719,6 @@ fn raw_generate_is_raw() {
}
}
#[test]
fn the_projector_is_asked_in_chat() {
let (endpoint, seen) = serve(vec![json!({ "message": { "role": "assistant", "content": "I hold it." } }).to_string()]);
let model = OllamaStream::open(&endpoint, "projector:x", "base:y").unwrap();
assert_eq!(model.project("Be the Projector.").unwrap(), "I hold it.");
let seen = seen.lock().unwrap();
assert_eq!(seen[0].0, "/api/chat");
assert_eq!(seen[0].1, json!({ "model": "projector:x", "stream": false, "messages": [{ "role": "user", "content": "Be the Projector." }] }));
}
#[test]
fn a_model_host_that_answers_badly_is_a_fault() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
@ -538,8 +729,8 @@ fn a_model_host_that_answers_badly_is_a_fault() {
let _ = socket.read(&mut buffer);
let _ = socket.write_all(b"HTTP/1.1 500 Internal Server Error\r\ncontent-length: 4\r\nconnection: close\r\n\r\nboom");
});
let model = OllamaStream::open(&endpoint, "p", "b").unwrap();
let fault = model.project("x").unwrap_err().to_string();
let model = OllamaContinuer::open(&endpoint, "b").unwrap();
let fault = model.continue_raw("x", &Sampling::STREAM, 1).unwrap_err().to_string();
assert!(fault.contains("500"), "{fault}");
}
@ -560,13 +751,13 @@ fn non_loopback_endpoint_refused() {
"http://",
"",
] {
assert!(OllamaStream::open(refused, "p", "b").is_err(), "{refused} must be refused");
assert!(OllamaContinuer::open(refused, "b").is_err(), "{refused} must be refused");
}
for refused in ["http://10.77.0.1:11434", "http://example.com:11434", "http://0.0.0.0:11434", "https://127.0.0.1:11434", "127.0.0.1:11434", "http://127.0.0.1:11434/api", "http://[2001:db8::1]:11434"] {
assert!(OllamaStream::open(refused, "p", "b").is_err(), "{refused} must be refused");
assert!(OllamaContinuer::open(refused, "b").is_err(), "{refused} must be refused");
}
for opened in ["http://127.0.0.1:11434", "http://localhost:11434", "http://[::1]:11434", "http://127.0.0.2:1/", "http://LOCALHOST:11434/"] {
assert!(OllamaStream::open(opened, "p", "b").is_ok(), "{opened} is loopback");
assert!(OllamaContinuer::open(opened, "b").is_ok(), "{opened} is loopback");
}
}
@ -706,7 +897,7 @@ fn old_repo_activity_does_not_make_a_tick_due() {
let model = Fake::saying("I turn it over.", &["A new drift"]);
let persona = CountedPersona(persona(), std::cell::Cell::new(0));
let wrap = Wrap::over(&mind, &[0.1]).with_forge(vec![push(1, 500)]);
let organs = Organs { mind: &wrap, persona: &persona, model: &model, projector_model: "projector:test", base_model: "base:test" };
let organs = Organs { mind: &wrap, persona: &persona, projector: &model, continuer: &model, base_model: "base:test" };
assert_eq!(tick(&organs, &|| released(), Mode::Live, t(1901)).unwrap(), Outcome::NotDue);
assert_eq!((model.calls(), persona.1.get(), thoughts(&mind).len()), (0, 0, 1), "no model call, no seed, no thought");
}
@ -777,7 +968,7 @@ fn every_thought_records_the_fixed_parts_of_its_prompts() {
assert!(version.len() <= LINE_MAX_BYTES, "{}", version.len());
assert!(include_str!("../../Cargo.toml").contains(GHOSTLIGHT_REV), "the recorded revision is the manifest's pin");
let rebuilt = build_projector_prompt(&ProjectorPrompt { identity: &thought.identity.0, typed_context: &thought.context.0, visible_stimulus: &thought.stimulus.0, domain_guidance: STREAM_GUIDANCE, word_budget: PROJECTOR_WORDS });
assert_eq!(model.projected.borrow()[0], rebuilt, "the trace rebuilds the Projector's exact prompt");
assert_eq!(model.projected.borrow()[0].1, rebuilt, "the trace rebuilds the Projector's exact prompt");
}

View file

@ -660,6 +660,8 @@ impl Child for Scripted {
pub(crate) struct Fake {
pub(crate) child: RefCell<Option<Scripted>>,
pub(crate) spawns: RefCell<Vec<Vec<String>>>,
/// The `HUGINN_SESSION` each spawn was given, in order with `spawns`.
pub(crate) sessions: RefCell<Vec<Option<String>>>,
pub(crate) refuse: bool,
/// The conversation the spawner reports as kept; a test sets it.
pub(crate) kept: RefCell<Option<Kept>>,
@ -670,7 +672,7 @@ pub(crate) struct Fake {
impl Fake {
pub(crate) fn with(child: Option<Scripted>, refuse: bool) -> Self {
let log = child.as_ref().map(|child| child.log.clone());
Self { child: RefCell::new(child), spawns: RefCell::new(Vec::new()), refuse, kept: RefCell::new(None), log }
Self { child: RefCell::new(child), spawns: RefCell::new(Vec::new()), sessions: RefCell::new(Vec::new()), refuse, kept: RefCell::new(None), log }
}
}
@ -686,8 +688,9 @@ impl Spawner for Fake {
Ok(())
}
fn spawn(&self, args: &[String], _token: &TokenFd) -> Result<Box<dyn Child>> {
fn spawn(&self, args: &[String], _token: &TokenFd, session: Option<&str>) -> Result<Box<dyn Child>> {
self.spawns.borrow_mut().push(args.to_vec());
self.sessions.borrow_mut().push(session.map(str::to_string));
if self.refuse {
return Err(anyhow!("refused"));
}

View file

@ -13,19 +13,32 @@ use std::thread::JoinHandle;
use std::time::Duration;
use cultnet_rs::{CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions};
use mind_body::chat_client::{self, ACK, Chat, ChatAck, ChatPending, ChatPendingRequest, ChatRead, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_V1, READ, SEND_RECEIPT, SEND_REQUEST};
use mind_body::chat_client::{self, Basis, Form, ChatRefusal, MouthRefused, Refusal, REFUSAL, ACK, Chat, ChatAck, ChatOpenDirectReceipt, ChatOpenDirectRequest, DirectOutcome, OPEN_DIRECT_RECEIPT, OPEN_DIRECT_REQUEST, ChatPending, ChatPendingRequest, ChatRead, ChatSendReceipt, ChatSendRequest, PENDING, PENDING_REQUEST, PENDING_REQUEST_V1, PENDING_V1, PENDING_V2, READ, SEND_RECEIPT, SEND_REQUEST};
use mind_body::chat_rudp::{RudpChat, connection_id};
const ACK_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_ack.v1.msgpack");
const PENDING_V1_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_pending.v1.msgpack");
const PENDING_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v3/gamecult.persona.chat_pending.v2.image.msgpack");
const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack");
const PENDING_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack");
const PENDING_TAILED_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.tag.msgpack");
const PENDING_TAIL_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v6/gamecult.persona.chat_pending.v3.tail.msgpack");
// The four-field `chat_pending.v3` of a mouth with `tail_gaps`: Bifrost b2e0bb2's own golden bytes, copied unchanged.
const PENDING_GAPS_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v7/gamecult.persona.chat_pending.v3.tail-gaps.msgpack");
const PENDING_V2_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v3/gamecult.persona.chat_pending.v2.image.msgpack");
const PENDING_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v5/gamecult.persona.chat_pending_request.v2.msgpack");
const PENDING_REQUEST_V1_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_pending_request.v1.msgpack");
const SEND_RECEIPT_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v2/gamecult.persona.chat_send_receipt.v2.sent.msgpack");
const SEND_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v2/gamecult.persona.chat_send_request.v2.nil-basis.msgpack");
const OPEN_REQUEST_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_request.v1.msgpack");
const UNKNOWN_KIND_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_refusal.v1.unknown-kind.msgpack");
const REFUSAL_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v1/gamecult.persona.chat_refusal.v1.msgpack");
const OPENED_BYTES: &[u8] = include_bytes!("fixtures/persona-chat-v4/gamecult.persona.chat_open_direct_receipt.v1.opened.msgpack");
const KEY: &str = "connection-key-for-test";
const TAG: &str = "cultnet.transport.rudp.v0";
/// What a fixture mouth answers an open with: the document kind, a record key (none: the request's own), and its bytes.
type OpenReply = (&'static str, Option<&'static str>, &'static [u8]);
type Seen = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
struct Mouth {
@ -66,17 +79,22 @@ fn mouth(key: &str) -> Mouth {
mouth_at(key, "127.0.0.1:0")
}
/// A mouth that has no image field yet: it answers a pending request in the v1 kind.
/// A mouth that has no image field yet: it answers a pending request in the v1 kind, and does not serve the v2 request.
fn legacy_mouth(key: &str) -> Mouth {
serve(key, "127.0.0.1:0", PENDING_V1, PENDING_V1_BYTES)
serve(key, "127.0.0.1:0", PENDING_V1, PENDING_V1_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES))
}
/// A mouth that has the image field and not the addressing facts: it answers the v1 request in the v2 kind, and does not serve the v2 request.
fn imaged_mouth(key: &str) -> Mouth {
serve(key, "127.0.0.1:0", PENDING_V2, PENDING_V2_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES))
}
/// The same mouth on a chosen address: a restart binds the address the old one held.
fn mouth_at(key: &str, bind: &str) -> Mouth {
serve(key, bind, PENDING, PENDING_BYTES)
serve(key, bind, PENDING, PENDING_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES))
}
fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'static [u8]) -> Mouth {
fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'static [u8], open: OpenReply) -> Mouth {
let socket = UdpSocket::bind(bind).unwrap();
socket.set_read_timeout(Some(Duration::from_millis(20))).unwrap();
let address = socket.local_addr().unwrap();
@ -94,8 +112,12 @@ fn serve(key: &str, bind: &str, pending_kind: &'static str, pending_bytes: &'sta
log.lock().unwrap().push((document.schema_id.clone(), document.payload.clone()));
let for_eureka = |persona: &str| persona == "eureka";
let reply = match document.schema_id.as_str() {
PENDING_REQUEST if for_eureka(&chat_client::decode::<ChatPendingRequest>(&document.payload).unwrap().persona) => Some(answer(pending_kind, "eureka", pending_bytes)),
PENDING_REQUEST if for_eureka(&chat_client::decode::<ChatPendingRequest>(&document.payload).unwrap().persona) => {
Some(if pending_kind == PENDING { answer(pending_kind, "eureka", pending_bytes) } else { answer(REFUSAL, "eureka", UNKNOWN_KIND_BYTES) })
}
PENDING_REQUEST_V1 if pending_kind != PENDING && for_eureka(&chat_client::decode::<ChatPendingRequest>(&document.payload).unwrap().persona) => Some(answer(pending_kind, "eureka", pending_bytes)),
SEND_REQUEST if for_eureka(&chat_client::decode::<ChatSendRequest>(&document.payload).unwrap().persona) => Some(answer(SEND_RECEIPT, &document.record_key, SEND_RECEIPT_BYTES)),
OPEN_DIRECT_REQUEST if for_eureka(&chat_client::decode::<ChatOpenDirectRequest>(&document.payload).unwrap().persona) => Some(answer(open.0, open.1.unwrap_or(&document.record_key), open.2)),
_ => None,
};
if let Some(reply) = reply {
@ -123,7 +145,7 @@ fn pending_send_and_ack_cross_a_real_rudp_session_in_the_mouths_bytes() {
let mouth = mouth(KEY);
let chat = client(&mouth, KEY, "eureka", Duration::from_secs(5));
let deliveries = chat.pending(3).unwrap();
let deliveries = chat.pending(3).unwrap().deliveries;
assert_eq!(deliveries, chat_client::decode::<ChatPending>(PENDING_BYTES).unwrap().deliveries);
assert_eq!(deliveries[0].seq, 7);
@ -185,8 +207,8 @@ fn a_client_recovers_from_a_restarted_mouth_on_its_next_call() {
drop(first);
let second = mouth_at(KEY, &address.to_string());
assert!(chat.pending(0).is_err(), "the restarted mouth ignores a link it never saw: the call times out");
assert_eq!(chat.pending(0).unwrap(), chat_client::decode::<ChatPending>(PENDING_BYTES).unwrap().deliveries, "the next call opens a fresh link");
assert_eq!(chat.pending(0).unwrap().len(), 1, "and keeps it");
assert_eq!(chat.pending(0).unwrap(), chat_client::decode::<ChatPending>(PENDING_BYTES).unwrap(), "the next call opens a fresh link");
assert_eq!(chat.pending(0).unwrap().deliveries.len(), 1, "and keeps it");
let reads = second.seen.lock().unwrap().iter().filter(|(kind, _)| kind == PENDING_REQUEST).count();
assert_eq!(reads, 2, "the restarted mouth read the two requests that reached it on a fresh link");
}
@ -203,13 +225,126 @@ fn a_send_that_timed_out_is_not_repeated_by_the_client() {
}
#[test]
fn a_v1_mouths_pending_answer_still_reads_with_no_image_and_a_v2_one_carries_it() {
let legacy = legacy_mouth(KEY);
let old = client(&legacy, KEY, "eureka", Duration::from_millis(1500)).pending(0).unwrap();
assert_eq!(old.len(), 1);
assert!(old[0].image.is_none());
let current = mouth(KEY);
let new = client(&current, KEY, "eureka", Duration::from_millis(1500)).pending(0).unwrap();
assert_eq!(new[0].image.as_ref().map(|image| (image.mime.as_str(), image.size)), Some(("image/png", 2048)));
assert_eq!(new[0].text, old[0].text, "the same message, with its attachment");
fn a_mouth_without_the_v2_request_is_asked_the_v1_way_and_its_deliveries_carry_no_addressing_facts() {
for (mouth, text) in [(legacy_mouth(KEY), "v1 answer"), (imaged_mouth(KEY), "v2 answer")] {
let old = client(&mouth, KEY, "eureka", Duration::from_millis(1500)).pending(3).unwrap();
assert_eq!(old.tail, None, "{text}: a v1 request is answered with no tail");
let old = old.deliveries;
assert_eq!(old.len(), 1, "{text}");
assert_eq!((old[0].basis, old[0].form), (None, Form::Message), "{text}");
let seen: Vec<String> = mouth.seen.lock().unwrap().iter().map(|(kind, _)| kind.clone()).collect();
assert_eq!(seen, [PENDING_REQUEST, PENDING_REQUEST_V1], "{text}: the v2 request first, refused as an unknown kind, then the v1 request");
assert_eq!(mouth.seen.lock().unwrap()[1].1, PENDING_REQUEST_V1_BYTES, "{text}");
}
assert!(client(&imaged_mouth(KEY), KEY, "eureka", Duration::from_millis(1500)).pending(0).unwrap().deliveries[0].image.is_some());
}
#[test]
fn a_current_mouth_answers_the_v2_request_with_basis_and_form() {
let current = mouth(KEY);
let new = client(&current, KEY, "eureka", Duration::from_millis(1500)).pending(3).unwrap().deliveries;
assert_eq!((new[0].basis, new[0].form), (Some(Basis::Tag), Form::Message));
let kinds: Vec<String> = current.seen.lock().unwrap().iter().map(|(kind, _)| kind.clone()).collect();
assert_eq!(kinds, [PENDING_REQUEST], "no v1 request follows an answered v2 request");
}
#[test]
fn an_open_crosses_a_real_rudp_session_in_the_mouths_bytes_and_a_receipt_for_another_open_is_refused() {
let mouth = mouth(KEY);
let chat = client(&mouth, KEY, "eureka", Duration::from_secs(5));
let request = chat_client::decode::<ChatOpenDirectRequest>(OPEN_REQUEST_BYTES).unwrap();
let receipt = chat.open_direct(&request).unwrap();
assert_eq!(receipt, chat_client::decode::<ChatOpenDirectReceipt>(OPENED_BYTES).unwrap());
assert_eq!(receipt.outcome, DirectOutcome::Opened);
let another = ChatOpenDirectRequest { request_id: "req-other".into(), ..request };
assert!(chat.open_direct(&another).is_err(), "the answer names req-1, not req-other");
let seen = mouth.seen.lock().unwrap().clone();
assert_eq!(seen[0], (OPEN_DIRECT_REQUEST.to_string(), OPEN_REQUEST_BYTES.to_vec()));
}
/// A mouth that answers every open with `reply`.
fn opening_with(reply: OpenReply) -> Mouth {
serve(KEY, "127.0.0.1:0", PENDING, PENDING_BYTES, reply)
}
#[test]
fn a_mouth_that_does_not_serve_the_open_kind_refuses_it_and_the_open_is_told_not_sent_at_once() {
let mouth = opening_with((REFUSAL, Some("eureka"), UNKNOWN_KIND_BYTES));
let chat = client(&mouth, KEY, "eureka", Duration::from_secs(20));
let request = chat_client::decode::<ChatOpenDirectRequest>(OPEN_REQUEST_BYTES).unwrap();
let started = std::time::Instant::now();
let error = chat.open_direct(&request).unwrap_err();
assert!(started.elapsed() < Duration::from_secs(5), "the refusal ends the wait: {:?}", started.elapsed());
assert_eq!(error.downcast_ref::<MouthRefused>(), Some(&MouthRefused(Refusal::UnknownKind)), "{error}");
}
#[test]
fn a_refusal_for_another_persona_or_another_kind_does_not_satisfy_the_open() {
let wrong_persona = opening_with((REFUSAL, Some("someone-else"), UNKNOWN_KIND_BYTES));
let request = chat_client::decode::<ChatOpenDirectRequest>(OPEN_REQUEST_BYTES).unwrap();
let error = client(&wrong_persona, KEY, "eureka", Duration::from_millis(800)).open_direct(&request).unwrap_err();
assert!(error.downcast_ref::<MouthRefused>().is_none(), "a refusal keyed by another persona is not heard: {error}");
let wrong_kind = opening_with((SEND_RECEIPT, Some("eureka"), SEND_RECEIPT_BYTES));
let error = client(&wrong_kind, KEY, "eureka", Duration::from_millis(800)).open_direct(&request).unwrap_err();
assert!(error.downcast_ref::<MouthRefused>().is_none(), "another kind is not heard: {error}");
}
#[test]
fn a_refusal_of_another_kind_ends_the_wait_but_is_not_told_as_not_sent() {
// The fixture refusal is persona "ghost", UnknownPersona: the mouth refuses this Persona's open for a reason other than the kind.
let mouth = opening_with((REFUSAL, Some("ghost"), REFUSAL_BYTES));
let request = chat_client::decode::<ChatOpenDirectRequest>(OPEN_REQUEST_BYTES).unwrap();
let started = std::time::Instant::now();
let error = client(&mouth, KEY, "ghost", Duration::from_secs(20)).open_direct(&request).unwrap_err();
assert!(started.elapsed() < Duration::from_secs(5), "the refusal ends the wait: {:?}", started.elapsed());
assert!(error.downcast_ref::<MouthRefused>().is_none(), "only UnknownKind is a known not-sent: {error}");
assert!(error.to_string().contains("UnknownPersona"), "{error}");
}
#[test]
fn an_unknown_kind_refusal_keyed_by_this_persona_but_naming_another_is_not_heard_as_not_sent() {
let other = chat_client::encode(&ChatRefusal { persona: "someone-else".into(), refusal: Refusal::UnknownKind }).unwrap();
let mouth = opening_with((REFUSAL, Some("eureka"), Box::leak(other.into_boxed_slice())));
let request = chat_client::decode::<ChatOpenDirectRequest>(OPEN_REQUEST_BYTES).unwrap();
let error = client(&mouth, KEY, "eureka", Duration::from_secs(20)).open_direct(&request).unwrap_err();
assert!(error.downcast_ref::<MouthRefused>().is_none(), "the payload names another Persona: {error}");
}
/// The wire, not only the decoder: the three-field `chat_pending.v3` of Bifrost e8722d0 crosses a real RUDP session as `chat_pending.v3`,
/// and the tail arrives; the two-field answer of the mouth before it (the fixture the other tests serve) arrives with none.
#[test]
fn the_three_field_v3_crosses_real_rudp_with_its_tail_and_the_two_field_one_with_none() {
let tailed = serve(KEY, "127.0.0.1:0", PENDING, PENDING_TAIL_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES));
let got = client(&tailed, KEY, "eureka", Duration::from_secs(5)).pending(0).unwrap();
assert_eq!(got, chat_client::decode::<ChatPending>(PENDING_TAIL_BYTES).unwrap());
assert_eq!(got.tail.as_ref().map(Vec::len), Some(2));
let with_delivery = serve(KEY, "127.0.0.1:0", PENDING, PENDING_TAILED_BYTES, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES));
let got = client(&with_delivery, KEY, "eureka", Duration::from_secs(5)).pending(0).unwrap();
assert_eq!((got.deliveries.len(), got.tail), (1, Some(vec![])), "a delivery and an empty tail: the mouth had nothing to add");
let old = client(&mouth(KEY), KEY, "eureka", Duration::from_secs(5)).pending(0).unwrap();
assert_eq!((old.deliveries.len(), old.tail), (1, None), "the older mouth's two-field answer carries no tail");
}
/// The three-field answer with a fourth element appended, as a four-field mouth would send it.
fn four_field(three: &[u8], gaps: &[&str]) -> &'static [u8] {
let mut value: serde_json::Value = rmp_serde::from_slice(three).unwrap();
value.as_array_mut().unwrap().push(serde_json::json!(gaps));
Box::leak(rmp_serde::to_vec(&value).unwrap().into_boxed_slice())
}
/// Deploy safety for `tail_gaps` (Bifrost b2e0bb2): the three-field answer, the four-field one with gaps and the four-field one with
/// none all cross real RUDP and decode. Only a named room is a gap; a quiet room is never in the list.
#[test]
fn the_four_field_v3_crosses_real_rudp_with_its_gaps_and_the_three_field_one_with_none() {
let fetch = |bytes: &'static [u8]| client(&serve(KEY, "127.0.0.1:0", PENDING, bytes, (OPEN_DIRECT_RECEIPT, None, OPENED_BYTES)), KEY, "eureka", Duration::from_secs(5)).pending(0).unwrap();
let three = fetch(PENDING_TAIL_BYTES);
assert_eq!((three.tail.as_ref().map(Vec::len), three.tail_gaps), (Some(2), Vec::<String>::new()), "a three-field answer names no gap");
let gapped = fetch(PENDING_GAPS_BYTES);
assert_eq!((gapped.deliveries.len(), gapped.tail.as_ref().map(Vec::len)), (0, Some(1)));
assert_eq!(gapped.tail_gaps, vec!["#swarm:gamecult.org".to_string(), "!dm1:gamecult.org".to_string()], "the mouth's own bytes name two rooms");
let empty = fetch(four_field(PENDING_TAIL_BYTES, &[]));
assert_eq!((empty.tail.as_ref().map(Vec::len), empty.tail_gaps), (Some(2), Vec::<String>::new()), "an empty fourth element is every room read fully");
let quiet = fetch(four_field(PENDING_TAIL_BYTES, &["#swarm:gamecult.org"]));
assert!(!quiet.tail_gaps.contains(&"#operator:gamecult.org".to_string()), "a room the mouth did not name is not a gap, however few lines it has");
}

View file

@ -275,7 +275,7 @@ fn brake_hold_stops_once_with_the_instance_and_the_store_already_held() {
result.unwrap();
assert_eq!(calls, [Some("eureka".to_string())]);
assert!(seen_at_stop.contains("BrakeHeld"), "the write comes before the stop: {seen_at_stop}");
assert!(out.contains("units: stopped mind-self@eureka.service mind-persona@eureka.service"), "{out}");
assert!(out.lines().any(|line| line == "units: stopped mind-self@eureka.service mind-persona@eureka.service mind-stream@eureka.service"), "{out}");
}
#[test]
@ -362,8 +362,8 @@ fn hold_through_the_binary(exit: i32) -> (std::process::Output, Vec<String>, tem
fn the_binary_hold_runs_systemctl_stop_on_exactly_the_units_it_starts() {
let (output, recorded, _root) = hold_through_the_binary(0);
assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr));
assert_eq!(recorded, ["stop", "--", "mind-self@eureka.service", "mind-persona@eureka.service"], "a stop that succeeds asks no state");
assert!(String::from_utf8_lossy(&output.stdout).contains("units: stopped mind-self@eureka.service mind-persona@eureka.service"));
assert_eq!(recorded, ["stop", "--", "mind-self@eureka.service", "mind-persona@eureka.service", "mind-stream@eureka.service"], "a stop that succeeds asks no state");
assert!(String::from_utf8_lossy(&output.stdout).contains("units: stopped mind-self@eureka.service mind-persona@eureka.service mind-stream@eureka.service"));
}
#[cfg(unix)]
@ -373,11 +373,11 @@ fn a_partial_stop_names_each_units_state_and_keeps_the_brake_held_with_exit_2()
assert_eq!(output.status.code(), Some(2));
assert_eq!(
recorded,
["stop", "--", "mind-self@eureka.service", "mind-persona@eureka.service", "is-active", "mind-self@eureka.service", "is-active", "mind-persona@eureka.service"]
["stop", "--", "mind-self@eureka.service", "mind-persona@eureka.service", "mind-stream@eureka.service", "is-active", "mind-self@eureka.service", "is-active", "mind-persona@eureka.service", "is-active", "mind-stream@eureka.service"]
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("exit status 5") && stderr.contains("the brake is held"), "{stderr}");
assert!(stderr.contains("mind-self@eureka.service is inactive") && stderr.contains("mind-persona@eureka.service is active"), "{stderr}");
assert!(stderr.contains("mind-self@eureka.service is inactive") && stderr.contains("mind-persona@eureka.service is active") && stderr.contains("mind-stream@eureka.service is active"), "{stderr}");
assert!(!stderr.contains("were not stopped"), "{stderr}");
assert!(!String::from_utf8_lossy(&output.stdout).contains("stopped"));
assert!(shown(root.path()).contains("BrakeHeld"));
@ -475,7 +475,7 @@ fn swarm_brake_hold_stops_once_with_the_swarm_scope_and_the_swarm_store_already_
result.unwrap();
assert_eq!(calls, [None], "one stop, for the swarm");
assert!(seen_at_stop.contains("BrakeHeld"), "the write comes before the stop: {seen_at_stop}");
assert!(out.contains("units: stopped every mind-self@ and mind-persona@ unit"), "{out}");
assert!(out.lines().any(|line| line == "units: stopped every mind-self@*.service mind-persona@*.service mind-stream@*.service"), "the line names exactly what stop_args stopped: {out}");
}
#[test]

View file

@ -0,0 +1 @@
”¥req-3¨DeclinedÀÀ

View file

@ -0,0 +1 @@
”¥req-2¨Existing±!dm1:gamecult.org¦$line2

View file

@ -0,0 +1 @@
”¥req-1¦Opened±!dm1:gamecult.org¦$line1

View file

@ -0,0 +1 @@
”¥req-1¦eureka²@meta:gamecult.orgªhello Meta

View file

@ -0,0 +1 @@
’¦eureka«UnknownKind

View file

@ -14,33 +14,59 @@ mind_body: mod tempo
mind_body::address: impl Addressee
mind_body::address: struct Addressee # [derive (Clone , Debug , PartialEq , Eq , serde :: Serialize , serde :: Deserialize)] pub-fields[]
mind_body::address: struct Name # [derive (Clone , Debug , PartialEq , Eq , serde :: Serialize , serde :: Deserialize)] pub-fields[]
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Refusal { Brake , PersonaMismatch , UnknownRoom , EmptyText , SendFailed , UnknownPersona , RoomMoved , }
mind_body::chat_client: # [derive (Clone , Copy , Debug , Default , PartialEq , Eq)] pub enum Form { # [default] Message , Notice , Edit , }
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Basis { Direct , Tag , Reply , Room , }
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum DirectOutcome { Opened , Existing , Brake , PersonaMismatch , UnknownPersona , EmptyText , ForeignUser , NotAPerson , NoSuchUser , InvitePending , Declined , DailyLimit , SendFailed , }
mind_body::chat_client: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Refusal { Brake , PersonaMismatch , UnknownRoom , EmptyText , SendFailed , UnknownPersona , RoomMoved , UnknownKind , }
mind_body::chat_client: const ACK : & str
mind_body::chat_client: const OPEN_DIRECT_RECEIPT : & str
mind_body::chat_client: const OPEN_DIRECT_REQUEST : & str
mind_body::chat_client: const PENDING : & str
mind_body::chat_client: const PENDING_REQUEST : & str
mind_body::chat_client: const PENDING_REQUEST_V1 : & str
mind_body::chat_client: const PENDING_V1 : & str
mind_body::chat_client: const PENDING_V2 : & str
mind_body::chat_client: const READ : & str
mind_body::chat_client: const REFUSAL : & str
mind_body::chat_client: const SEND_RECEIPT : & str
mind_body::chat_client: const SEND_REQUEST : & str
mind_body::chat_client: fn decode < T : DeserializeOwned > (bytes : & [u8]) -> Result < T >
mind_body::chat_client: fn encode < T : Serialize > (document : & T) -> Result < Vec < u8 > >
mind_body::chat_client: impl Basis
mind_body::chat_client: impl Basis: fn name (self) -> & 'static str
mind_body::chat_client: impl DirectOutcome
mind_body::chat_client: impl DirectOutcome: fn name (self) -> & 'static str
mind_body::chat_client: impl Form
mind_body::chat_client: impl Form: fn name (self) -> & 'static str
mind_body::chat_client: impl Refusal
mind_body::chat_client: impl Refusal: fn name (self) -> & 'static str
mind_body::chat_client: impl Serialize for Basis
mind_body::chat_client: impl Serialize for DirectOutcome
mind_body::chat_client: impl Serialize for Form
mind_body::chat_client: impl Serialize for Refusal
mind_body::chat_client: impl TryFrom < RawOpenReceipt > for ChatOpenDirectReceipt
mind_body::chat_client: impl std :: error :: Error for MouthRefused
mind_body::chat_client: impl std :: fmt :: Display for MouthRefused
mind_body::chat_client: impl< 'de > Deserialize < 'de > for Basis
mind_body::chat_client: impl< 'de > Deserialize < 'de > for DirectOutcome
mind_body::chat_client: impl< 'de > Deserialize < 'de > for Form
mind_body::chat_client: impl< 'de > Deserialize < 'de > for Refusal
mind_body::chat_client: pub trait Chat { fn pending (& self , after_seq : u64) -> Result < Vec < ChatDelivery > > ; fn send (& self , request : & ChatSendRequest) -> Result < ChatSendReceipt > ; fn ack (& self , through_seq : u64) -> Result < () > ; fn read (& self , read : & ChatRead) -> Result < () > ; }
mind_body::chat_client: pub trait Chat { fn pending (& self , after_seq : u64) -> Result < ChatPending > ; fn send (& self , request : & ChatSendRequest) -> Result < ChatSendReceipt > ; fn open_direct (& self , request : & ChatOpenDirectRequest) -> Result < ChatOpenDirectReceipt > ; fn ack (& self , through_seq : u64) -> Result < () > ; fn read (& self , read : & ChatRead) -> Result < () > ; }
mind_body::chat_client: struct ChatAck # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, through_seq: u64]
mind_body::chat_client: struct ChatDelivery # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seq: u64, room_alias: String, room_id: String, event_id: String, sender: String, sender_display: String, text: String, at_unix_ms: u64, context: Vec < ContextLine >, mentions: Vec < String >, is_direct: bool, image: Option < ChatImage >]
mind_body::chat_client: struct ChatDelivery # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seq: u64, room_alias: String, room_id: String, event_id: String, sender: String, sender_display: String, text: String, at_unix_ms: u64, context: Vec < ContextLine >, mentions: Vec < String >, is_direct: bool, image: Option < ChatImage >, basis: Option < Basis >, form: Form]
mind_body::chat_client: struct ChatImage # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[path: String, mime: String, size: u64, sha256: String]
mind_body::chat_client: struct ChatPending # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, deliveries: Vec < ChatDelivery >]
mind_body::chat_client: struct ChatOpenDirectReceipt # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, outcome: DirectOutcome, room_id: Option < String >, event_id: Option < String >]
mind_body::chat_client: struct ChatOpenDirectRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, persona: String, user_id: String, text: String]
mind_body::chat_client: struct ChatPending # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, deliveries: Vec < ChatDelivery >, tail: Option < Vec < TailLine > >, tail_gaps: Vec < String >]
mind_body::chat_client: struct ChatPendingRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, after_seq: u64]
mind_body::chat_client: struct ChatRead # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, room: String, event_id: String, at_unix_ms: u64]
mind_body::chat_client: struct ChatRefusal # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[persona: String, refusal: Refusal]
mind_body::chat_client: struct ChatSendReceipt # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, event_id: Option < String >, refusal: Option < Refusal >, newer: Vec < String >]
mind_body::chat_client: struct ChatSendRequest # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[request_id: String, persona: String, room_alias: String, text: String, basis: Option < SendBasis >]
mind_body::chat_client: struct ContextLine # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[sender: String, text: String, at_unix_ms: u64]
mind_body::chat_client: struct MouthRefused # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub-fields[0: Refusal]
mind_body::chat_client: struct SendBasis # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[seen_event_id: Option < String >, since_unix_ms: u64]
mind_body::chat_client: struct TailLine # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[room: String, event_id: String, at_unix_ms: u64, sender: String, form: Form, basis: Option < Basis >, body: String]
mind_body::chat_image: impl Skip
mind_body::chat_rudp: fn connection_id (key_file_text : & str) -> u32
mind_body::chat_rudp: impl Chat for RudpChat
@ -163,7 +189,7 @@ mind_body::lease_client: struct SocketLender pub-fields[]
mind_body::lease_client: struct TokenFd pub-fields[]
mind_body::lease_client::socket: impl Lease for Granted
mind_body::persona: # [derive (Clone , Copy , Debug , PartialEq , Eq , Serialize , Deserialize)] # [serde (rename_all = "lowercase")] pub enum SettleOutcome { Done , Declined , Wrong , }
mind_body::persona: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Failure { Held (HeldReason) , Chat , Persona , Doctrine , DoctrineTooLarge , Lease , Session , Undecodable , OutOfBounds (& 'static str) , RoomNotAllowed , Send , SelfRun , Memory , Ack , }
mind_body::persona: # [derive (Clone , Debug , PartialEq , Eq)] pub enum Failure { Held (HeldReason) , Chat , Persona , Doctrine , DoctrineTooLarge , Lease , Session , Undecodable , OutOfBounds (& 'static str) , RuleRepo (String) , RoomNotAllowed , Send , SelfRun , Memory , Ack , }
mind_body::persona: # [derive (Debug , PartialEq , Eq)] pub enum Outcome { Dead (HeldReason) , NoRun , Served { run : PipelineRef , answered : u32 , failure : Option < Failure > , self_run : Option < PipelineRef > } , }
mind_body::persona: const AGENT : & str
mind_body::persona: const DOCTRINE_MAX_BYTES : usize
@ -179,7 +205,7 @@ mind_body::persona: const ENV_SELF_SENDER : & str
mind_body::persona: const PERSONA_POLL_S : i64
mind_body::persona: const SANDBOX_DIR : & str
mind_body::persona: fn carry (organs : & Organs , control : & impl ControlSource , swarm : & impl ControlSource , config : & Config) -> Result < Outcome >
mind_body::persona: fn reply_schema (rooms : & [String] , campaigns : & [String]) -> String
mind_body::persona: fn reply_schema (rooms : & [String] , campaigns : & [String] , may_open : bool) -> String
mind_body::persona: impl Config
mind_body::persona: impl Config: fn from_args (args : & [String] , usage : & str) -> Result < (Self , Vec < String >) >
mind_body::persona: impl Config: fn from_args_and_env (args : & [String] , lookup : impl Fn (& str) -> Option < String > , usage : & str) -> Result < (Self , Vec < String >) >
@ -205,9 +231,11 @@ mind_body::persona: pub use model_connector :: PlanQuota ;
mind_body::persona: struct Config # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[rooms: Vec < String >, operators: Vec < String >, minds: Vec < String >, own_sender: String, model: String, doctrine: Vec < PathBuf >, image_store: Option < PathBuf >]
mind_body::persona: struct FileTrace pub-fields[]
mind_body::persona: struct HuginnPersona pub-fields[]
mind_body::persona: struct OpenDirect # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[with: String, text: String]
mind_body::persona: struct Organs< 'a > pub-fields[mind: & 'a dyn MindPort, launcher: & 'a dyn Launcher, clock: & 'a dyn Clock, host: & 'a str, chat: & 'a dyn Chat, persona: & 'a dyn PersonaState, trace: & 'a dyn Trace, heartbeat: Option < & 'a dyn HeartbeatView >, lender: & 'a dyn Lender, spawner: & 'a dyn Spawner, run_cap_s: u64, sandbox: & 'a Path, scratch: & 'a Path]
mind_body::persona: struct PersonaReply # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[intent: String, say: Vec < Say >, start_self_run: Option < StartSelfRun >, remember: Vec < Remember >, settle: Vec < Settle >, tempo_vote_beat_s: Option < Option < u32 > >, urgency_permille: Option < u16 >]
mind_body::persona: struct PersonaReply # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[intent: String, say: Vec < Say >, open_direct: Option < OpenDirect >, start_self_run: Option < StartSelfRun >, remember: Vec < Remember >, rule: Vec < Rule >, settle: Vec < Settle >, tempo_vote_beat_s: Option < Option < u32 > >, urgency_permille: Option < u16 >]
mind_body::persona: struct Remember # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[sort: MemorySort, text: String, about: Vec < String >, salience_permille: u16]
mind_body::persona: struct Rule # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[by: String, words: String, repo: String, campaign: String, title: String, ruling: String]
mind_body::persona: struct Say # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[room: String, text: String]
mind_body::persona: struct Settle # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[item: String, outcome: SettleOutcome, why: String]
mind_body::persona: struct StartSelfRun # [derive (Clone , Debug , PartialEq , Eq , Serialize , Deserialize)] pub-fields[brief: String, repo: String, campaign: String, for_item: Option < String >]
@ -281,7 +309,7 @@ mind_body::self_run: impl std :: error :: Error for SeedLinkRefused
mind_body::self_run: impl< 'a > SelfInbox < 'a >
mind_body::self_run: mod inbox
mind_body::self_run: pub trait Child { fn write_line (& mut self , line : & str) ; fn close_input (& mut self) ; fn next (& mut self , timeout : StdDuration) -> Read ; fn stop (& mut self , how : Stop) -> bool ; }
mind_body::self_run: pub trait Spawner { fn spawn (& self , args : & [String] , token : & TokenFd) -> Result < Box < dyn Child > > ; fn kept (& self) -> Option < Kept > { None } fn keep (& self , _keep : Option < (Uuid , DateTime < Utc >) >) -> Result < () > { Ok (()) } }
mind_body::self_run: pub trait Spawner { fn spawn (& self , args : & [String] , token : & TokenFd , session : Option < & str >) -> Result < Box < dyn Child > > ; fn kept (& self) -> Option < Kept > { None } fn keep (& self , _keep : Option < (Uuid , DateTime < Utc >) >) -> Result < () > { Ok (()) } }
mind_body::self_run: struct ClaudeSpawner pub-fields[]
mind_body::self_run: struct Kept # [derive (Clone , Debug , PartialEq , Eq)] pub-fields[session: Uuid, transcript: PathBuf, warm_until: DateTime < Utc >, written_at: DateTime < Utc >, same_seed: bool]
mind_body::self_run::decide: impl State
@ -300,20 +328,26 @@ mind_body::stream: const AGENT : & str
mind_body::stream: const STREAM_IDLE_GAP_S : i64
mind_body::stream: const STREAM_MIN_GAP_S : i64
mind_body::stream: const STREAM_POLL_S : i64
mind_body::stream: fn connector_key (credentials : & std :: path :: Path) -> Result < String >
mind_body::stream: fn tick (organs : & Organs , brake : & dyn Fn () -> Effective , mode : Mode , now : DateTime < Utc >) -> Result < Outcome >
mind_body::stream: impl Continuer for OllamaContinuer
mind_body::stream: impl HuginnPersonaSource
mind_body::stream: impl HuginnPersonaSource: fn new (client : HuginnClient) -> Self
mind_body::stream: impl OllamaStream
mind_body::stream: impl OllamaStream: fn open (endpoint : & str , projector_model : & str , base_model : & str) -> Result < Self >
mind_body::stream: impl KitProjector
mind_body::stream: impl KitProjector: fn open (endpoint : SocketAddr , connection_key : String , caller : & str , model : & str , timeout : Duration) -> Result < Self >
mind_body::stream: impl OllamaContinuer
mind_body::stream: impl OllamaContinuer: fn open (endpoint : & str , base_model : & str) -> Result < Self >
mind_body::stream: impl PersonaSource for HuginnPersonaSource
mind_body::stream: impl Projector for KitProjector
mind_body::stream: impl Sampling
mind_body::stream: impl Seed
mind_body::stream: impl StreamModel for OllamaStream
mind_body::stream: pub trait Continuer { fn continue_raw (& self , prompt : & str , sampling : & Sampling , n : usize) -> Result < Vec < String > > ; }
mind_body::stream: pub trait PersonaSource { fn persona (& self) -> Result < PersonaRead > ; }
mind_body::stream: pub trait StreamModel { fn project (& self , prompt : & str) -> Result < String > ; fn continue_raw (& self , prompt : & str , sampling : & Sampling , n : usize) -> Result < Vec < String > > ; }
mind_body::stream: pub trait Projector { fn model (& self) -> & str ; fn project (& self , request_id : & str , prompt : & str) -> Result < String > ; }
mind_body::stream: struct HuginnPersonaSource pub-fields[]
mind_body::stream: struct OllamaStream pub-fields[]
mind_body::stream: struct Organs< 'a > pub-fields[mind: & 'a dyn MindPort, persona: & 'a dyn PersonaSource, model: & 'a dyn StreamModel, projector_model: & 'a str, base_model: & 'a str]
mind_body::stream: struct KitProjector pub-fields[]
mind_body::stream: struct OllamaContinuer pub-fields[]
mind_body::stream: struct Organs< 'a > pub-fields[mind: & 'a dyn MindPort, persona: & 'a dyn PersonaSource, projector: & 'a dyn Projector, continuer: & 'a dyn Continuer, base_model: & 'a str]
mind_body::stream: struct PersonaRead pub-fields[state: Value, receipt_id: String]
mind_body::stream: struct Sampling # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub-fields[temperature_permille: u16, min_p_permille: u16, max_tokens: u16]
mind_body::tempo: const DEFAULT_BEAT_S : u32

View file

@ -0,0 +1,51 @@
//! The `mind-stream` binary's startup reads the connector key from systemd's credentials directory:
//! an absent directory names the variable, and the key's bytes never reach stderr.
use std::process::{Command, Output};
const CANARY: &str = "zq7Kx-9wVtm3Pd";
fn run(credentials: Option<&std::path::Path>, connector: &str) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_mind-stream"));
command
.args(["--instance", "eureka", "--once"])
.env_clear()
.env("HUGINN_ENDPOINT", "rudp://127.0.0.1:9")
.env("MIND_STREAM_CONNECTOR_ENDPOINT", connector)
.env("MIND_STREAM_PROJECTOR_MODEL", "claude-haiku-5-5")
.env("MIND_STREAM_BASE_MODEL", "base:test")
.env("MIND_STREAM_ENDPOINT", "http://127.0.0.1:1");
if let Some(dir) = credentials {
command.env("CREDENTIALS_DIRECTORY", dir);
}
command.output().unwrap()
}
#[test]
fn a_missing_credentials_directory_names_the_variable_and_a_missing_key_names_the_credential() {
let output = run(None, "127.0.0.1:4103");
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1));
assert!(stderr.contains("CREDENTIALS_DIRECTORY"), "{stderr}");
let empty = tempfile::tempdir().unwrap();
let stderr = String::from_utf8_lossy(&run(Some(empty.path()), "127.0.0.1:4103").stderr).to_string();
assert!(stderr.contains("connector-key"), "{stderr}");
}
/// Every four-byte run of the key: a partial key is as much a leak as the whole.
fn leaks(shown: &str) -> Option<&str> {
CANARY.as_bytes().windows(4).map(|window| std::str::from_utf8(window).unwrap()).find(|run| shown.contains(run))
}
#[test]
fn no_byte_run_of_the_key_is_printed_on_any_startup_path() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("connector-key"), format!("{CANARY}\n")).unwrap();
// A non-loopback connector and a configuration the client refuses both end startup after the key was read and handed over.
for (connector, ending) in [("10.77.0.1:4103", "not a loopback address"), ("127.0.0.1:0", "could not be configured")] {
let output = run(Some(dir.path()), connector);
let shown = format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
assert!(shown.contains(ending), "{connector}: the key was read and startup went on: {shown}");
assert_eq!(leaks(&shown), None, "{connector}: {shown}");
}
}

View file

@ -15,7 +15,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use mind_body::stream::{OllamaStream, StreamModel};
use mind_body::stream::{Continuer, OllamaContinuer, Sampling};
/// Counts the connections a loopback listener accepts, answering each with `reply` when given.
fn listen(reply: Option<String>) -> (String, Arc<AtomicUsize>) {
@ -67,7 +67,8 @@ fn the_stream_agent_ignores_the_proxy_environment() {
let plain: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(2))).build().into();
let _ = plain.get(&endpoint).call();
} else {
let _ = OllamaStream::open(&endpoint, "p", "b").unwrap().project("hello");
let sampling = Sampling { temperature_permille: 1000, min_p_permille: 50, max_tokens: 16 };
let _ = OllamaContinuer::open(&endpoint, "b").unwrap().continue_raw("hello", &sampling, 1);
}
return;
}

View file

@ -864,7 +864,7 @@ Ruling `eureka-body:ruling:persona-session-landed` (landed; deployed 2026-11-01
Rulings: `tell-is-just-the-message`, `tell-delta-three-defaults`, `what-reaches-each-turn-ruled`. Owner: `persona.rs`. A session is one process and nothing compacts it, so what she was told stays in her context; a line carries only what is new.
- **First line of a process** (`PersonaInbox::start_line` -> `whole_line` -> `render_input`): `TURN_INSTRUCTION` (the Perfect Machine, stewardship, inversion of control, originator vision as evidence, her tools, work that changes a repo goes to a Self run), a refused batch's note if an earlier process left one, her latest runs, responsibilities, recall, plan windows and tempo, the rooms and people she may name, `MESSAGES_HEADER`, the messages, and the tail: an opener when there are no messages (`WAKE_ASK` on a day she has started no Self run of her own, else "No new messages."), then, when there are messages, `ANSWER_MESSAGES`, then `WORK_QUESTION` on every first line, with messages or without. Later lines end on `WORK_TAIL` (`tell_line`: plain, refused and stale retells alike). A wake is always a first line: a session starts with an empty batch and gets the whole machine, so a wake never arrives as a bare later line. A restart (cache TTL, cap rotation, ceiling flush, failed result) is a new process and tells the whole machine again.
- **First line of a process** (`PersonaInbox::start_line` -> `whole_line` -> `render_input`): `TURN_INSTRUCTION` (the Perfect Machine, stewardship, inversion of control, originator vision as evidence, her tools, work that changes a repo goes to a Self run), a refused batch's note if an earlier process left one, her latest runs, responsibilities, recall, plan windows and tempo, the rooms and people she may name, the room tail (`render_tail`: the mouth's `tail` on `chat_pending.v3`, field 2, a defaulted `Option` so an older two-field mouth still decodes and is told the tail is unavailable; per speakable room the newest 20 and 60 in all, at most 24 KiB as rendered, framed as untrusted data with a truncation marker and a note naming rooms with no line; a delivery's event id drops its tail line; a DM session reads its own room alone; nothing is accumulated in the organ; ruling `wake-reads-room-tail`; deploy order: Huginn must deploy before Bifrost #51, because a Huginn without the `tail` field fails to decode the mouth's three-field reply), `MESSAGES_HEADER`, the messages, and the tail: an opener when there are no messages (`WAKE_ASK` on a day she has started no Self run of her own, else "No new messages."), then, when there are messages, `ANSWER_MESSAGES`, then `WORK_QUESTION` on every first line, with messages or without. Later lines end on `WORK_TAIL` (`tell_line`: plain, refused and stale retells alike). A wake is always a first line: a session starts with an empty batch and gets the whole machine, so a wake never arrives as a bare later line. A restart (cache TTL, cap rotation, ceiling flush, failed result) is a new process and tells the whole machine again.
- **Later line** (`tell_line`): the organ's notice lines first, a refused batch's note before the messages, then `earlier`/`message` lines, then `WORK_TAIL`. No instruction, no machine; `tell_line` reads nothing from her mind.
- **Notice lines**: `notice role=organ at=... text="Your Self run <label> ended: <outcome>"`. They are not messages and answer to no delivery. Source: one read of her Self run slot (`holders` for SelfRun) at most once per `SLOT_READ_EVERY_S`=15 s (the chat poll is 5 s; the slot read cost 1.4-2 s of daemon CPU on Eureka's 14.7k-document mind, so 5 s was 28-41% of a core); `slot_seen` holds the runs last seen holding it, whoever started them, and her own start joins it unread. A run seen to leave gets its closing read once. A failed read keeps `slot_seen` and delays the notice. Only a live session fills `ended`, so a notice never starts a session; with notices and no deliveries `poll` reports pending and `tell` writes a line of notices alone. A run she did not start that opens and ends between two reads is unseen (about 10 of 157 Self runs at 15 s); the next first line shows it.
- **Flush** (`flush_text`): `TURN_REMINDER` (the stewardship sentence) plus the ask, never the first-line instruction. It records what it shows (`flush-shown`).

View file

@ -80,13 +80,56 @@
"type": "string"
},
"RulingAuthority": {
"enum": [
"Operator",
"Standing",
"Defaulted",
"Mind"
],
"type": "string"
"description": "Who decided a ruling, and under what. `Mind` is this mind's own decision, admitted by her Self. `Steward` is\na named peer's decision inside the peer's stewardship, as this mind heard it: `steward` is the deciding Mind's\nMatrix id, `repo` the stewardship she decided under, `words` her own words copied from the line this mind heard,\n`event` that line's Matrix event id when it was a delivery. The four unit variants stay bare strings on the wire.",
"oneOf": [
{
"enum": [
"Operator",
"Standing",
"Defaulted",
"Mind"
],
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"Steward": {
"properties": {
"event": {
"anyOf": [
{
"$ref": "#/$defs/Short"
},
{
"type": "null"
}
]
},
"repo": {
"$ref": "#/$defs/OrgRepo"
},
"steward": {
"$ref": "#/$defs/Short"
},
"words": {
"$ref": "#/$defs/Para"
}
},
"required": [
"steward",
"repo",
"words"
],
"type": "object"
}
},
"required": [
"Steward"
],
"type": "object"
}
]
},
"Sha256Hex": {
"description": "A SHA-256 digest: exactly 64 lowercase hex characters.",

View file

@ -0,0 +1,229 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "DeskRequest",
"description": "Every request the desk door answers. The fields are the wire names: the `mind` addressed\n(the persona and pipeline requests say `instance`; the sets are separate), the verified\n`subject` and `authority` Bifrost vouches for (door context, never body fields), and the\n`deciders` of this call (1 to 64, distinct; the Mind never stores them). Closed: a key outside\nthe set refuses the whole payload.\n\nThe reads, `Forks` and `Fork`, carry NO `authority` (CultLib `list_request` and\n`read_request` are closed and have none; one that names it is refused whole). Their `subject`\nand `deciders` gate nothing: `deciders` only fills the card's `deciders` and the showability\ngate, `subject` is read by no read. `Forks` takes the contract's `list_request` fields flat\nbeside them (`status`, `cursor`, `limit`), each key written, `null` where absent. Huginn\nanswers every read with the whole record and Bifrost applies visibility; see `DeskResponse`\nfor the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the\nreads spec.",
"oneOf": [
{
"type": "object",
"properties": {
"Answer": {
"type": "object",
"properties": {
"authority": {
"$ref": "#/$defs/DeskAuthority"
},
"deciders": {
"type": "array",
"items": {
"$ref": "#/$defs/Short"
},
"maxItems": 64,
"minItems": 1,
"uniqueItems": true
},
"mind": {
"$ref": "#/$defs/Slug"
},
"request": {
"$ref": "#/$defs/ForkAnswerRequest"
},
"subject": {
"$ref": "#/$defs/Short"
}
},
"additionalProperties": false,
"required": [
"mind",
"subject",
"authority",
"deciders",
"request"
]
}
},
"additionalProperties": false,
"required": [
"Answer"
]
},
{
"type": "object",
"properties": {
"Forks": {
"type": "object",
"properties": {
"cursor": {
"type": "string",
"pattern": "^c[1-9][0-9]*$"
},
"deciders": {
"type": "array",
"items": {
"$ref": "#/$defs/Short"
},
"maxItems": 64,
"minItems": 1,
"uniqueItems": true
},
"limit": {
"$ref": "#/$defs/ForkLimit"
},
"mind": {
"$ref": "#/$defs/Slug"
},
"status": {
"description": "Which forks a list asks for; absent is both. `recorded` is a closed card.",
"type": "string",
"enum": [
"open",
"recorded"
]
},
"subject": {
"$ref": "#/$defs/Short"
}
},
"additionalProperties": false,
"required": [
"mind",
"subject",
"deciders",
"status",
"cursor",
"limit"
]
}
},
"additionalProperties": false,
"required": [
"Forks"
]
},
{
"type": "object",
"properties": {
"Fork": {
"type": "object",
"properties": {
"deciders": {
"type": "array",
"items": {
"$ref": "#/$defs/Short"
},
"maxItems": 64,
"minItems": 1,
"uniqueItems": true
},
"fork_id": {
"type": "string"
},
"mind": {
"$ref": "#/$defs/Slug"
},
"subject": {
"$ref": "#/$defs/Short"
}
},
"additionalProperties": false,
"required": [
"mind",
"subject",
"deciders",
"fork_id"
]
}
},
"additionalProperties": false,
"required": [
"Fork"
]
}
],
"$defs": {
"DeskAuthority": {
"type": "string",
"enum": [
"Operator",
"Steward",
"Member"
]
},
"ForkAnswerRequest": {
"description": "`gamecult.fork.answer_request.v0`: closed, her field order. The wire types hold text; the\nshape step of `Mind::answer_desk` judges it. The subject and the authority are the\ndoor's context and are no field here.",
"type": "object",
"properties": {
"answer": {
"description": "The person's words: a choice, a note, or both.",
"type": "object",
"properties": {
"choice": {
"type": "string"
},
"note": {
"type": "string"
}
},
"additionalProperties": false,
"required": [
"choice",
"note"
]
},
"answered_at": {
"type": "string"
},
"fork_id": {
"type": "string"
},
"options_hash": {
"type": "string"
},
"request_id": {
"type": "string"
},
"retracted": {
"type": "boolean"
},
"retracts": {
"type": "string"
},
"schemaVersion": {
"$ref": "#/$defs/RequestSchema"
}
},
"additionalProperties": false,
"required": [
"schemaVersion",
"fork_id",
"options_hash",
"answer",
"retracted",
"retracts",
"answered_at",
"request_id"
]
},
"ForkLimit": {
"type": "integer",
"maximum": 50,
"minimum": 0
},
"RequestSchema": {
"description": "The request document's schema id; any other value fails to decode.",
"type": "string",
"enum": [
"gamecult.fork.answer_request.v0"
]
},
"Short": {
"description": "Text of at most 200 UTF-8 bytes.",
"type": "string",
"maxLength": 200
},
"Slug": {
"description": "A campaign slug or key segment: label parts joined by `.`.",
"type": "string",
"maxLength": 64
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -2929,12 +2929,55 @@
]
},
"RulingAuthority": {
"type": "string",
"enum": [
"Operator",
"Standing",
"Defaulted",
"Mind"
"description": "Who decided a ruling, and under what. `Mind` is this mind's own decision, admitted by her Self. `Steward` is\na named peer's decision inside the peer's stewardship, as this mind heard it: `steward` is the deciding Mind's\nMatrix id, `repo` the stewardship she decided under, `words` her own words copied from the line this mind heard,\n`event` that line's Matrix event id when it was a delivery. The four unit variants stay bare strings on the wire.",
"oneOf": [
{
"type": "string",
"enum": [
"Operator",
"Standing",
"Defaulted",
"Mind"
]
},
{
"type": "object",
"properties": {
"Steward": {
"type": "object",
"properties": {
"event": {
"anyOf": [
{
"$ref": "#/$defs/Short"
},
{
"type": "null"
}
]
},
"repo": {
"$ref": "#/$defs/OrgRepo"
},
"steward": {
"$ref": "#/$defs/Short"
},
"words": {
"$ref": "#/$defs/Para"
}
},
"required": [
"steward",
"repo",
"words"
]
}
},
"additionalProperties": false,
"required": [
"Steward"
]
}
]
},
"RunOperator": {

View file

@ -5891,12 +5891,55 @@
]
},
"RulingAuthority": {
"type": "string",
"enum": [
"Operator",
"Standing",
"Defaulted",
"Mind"
"description": "Who decided a ruling, and under what. `Mind` is this mind's own decision, admitted by her Self. `Steward` is\na named peer's decision inside the peer's stewardship, as this mind heard it: `steward` is the deciding Mind's\nMatrix id, `repo` the stewardship she decided under, `words` her own words copied from the line this mind heard,\n`event` that line's Matrix event id when it was a delivery. The four unit variants stay bare strings on the wire.",
"oneOf": [
{
"type": "string",
"enum": [
"Operator",
"Standing",
"Defaulted",
"Mind"
]
},
{
"type": "object",
"properties": {
"Steward": {
"type": "object",
"properties": {
"event": {
"anyOf": [
{
"$ref": "#/$defs/Short"
},
{
"type": "null"
}
]
},
"repo": {
"$ref": "#/$defs/OrgRepo"
},
"steward": {
"$ref": "#/$defs/Short"
},
"words": {
"$ref": "#/$defs/Para"
}
},
"required": [
"steward",
"repo",
"words"
]
}
},
"additionalProperties": false,
"required": [
"Steward"
]
}
]
},
"RunId": {

View file

@ -262,6 +262,26 @@
"schemaVersion": "huginn.mind_response.v1",
"title": "Huginn mind response",
"path": "huginn.mind_response.v1.schema.json"
},
{
"schemaId": "huginn.desk_request.v1",
"kind": "wire_message",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "huginn.desk_request.v1",
"title": "Huginn desk request",
"path": "huginn.desk_request.v1.schema.json"
},
{
"schemaId": "huginn.desk_response.v1",
"kind": "wire_message",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "huginn.desk_response.v1",
"title": "Huginn desk response",
"path": "huginn.desk_response.v1.schema.json"
}
]
}