272 lines
19 KiB
Markdown
272 lines
19 KiB
Markdown
# Desk feed door: the list and read wire between Bifrost and a Mind's Huginn
|
|
|
|
Status, 2026-11-11: the list and read wire described here is on `main` (`DeskRequest::Forks`,
|
|
`DeskRequest::Fork`, `DeskResponse::Forks`, `DeskResponse::Fork`; `crates/huginn-mind/src/desk_wire.rs`
|
|
and `desk_wire/reads.rs`; the socket is `crates/huginn-daemon/src/desk_door.rs`). No running daemon
|
|
binds a desk socket yet (follow-up `eureka-substrate:follow_up:desk-door-ops-root-run`). The reader
|
|
shapes are built: the card's `raised_at` and `decider_count`, the record's `answerer_count`, the
|
|
nullable `answerer`, and the withholding of account ids from a non-decider (below). The listing gate
|
|
is built too: a reader who is not a decider sees a fork only while the Mind lists it (Listing, below).
|
|
Both are `cut-desk-feed-raised-at` and `cut-desk-feed-listing` in the `eureka-substrate` campaign.
|
|
|
|
**Operator note.** After deploy, a non-decider's Desk row for a Mind is empty until that Mind lists a
|
|
fork. Say so in #minds before the feed goes live. Neither Bifrost's receiver nor the ops root-run
|
|
(`desk-door-ops-root-run`) goes live on a Mind holding private questions before this is deployed.
|
|
|
|
This page is the wire contract for the consumer, Bifrost's `UnixSocketDeskDoor` (Bifrost PR #67,
|
|
`docs/desk-feed-design.md`). The golden bytes are in `crates/huginn-mind/tests/fixtures/desk-feed/`.
|
|
Huginn's own encoders write them (`crates/huginn-mind/tests/desk_feed_fixtures.rs`), so a decoder
|
|
that reads them reads the door. The reader-count shapes are CultLib's bytes, vendored in
|
|
`cultlib-d3025d3c/` (below); Huginn's types decode and re-encode every vendored vector byte for byte. The document shapes inside the payload (`gamecult.fork.list.v0`,
|
|
`gamecult.fork.entry.v0`, `gamecult.fork.card.v0`, `gamecult.fork.record.v0`,
|
|
`gamecult.desk_answer.v0`, `gamecult.fork.refusal.v0`) are CultLib's
|
|
(`contracts/cultnet/governance/fork-card.md`) and are not restated here.
|
|
|
|
## One socket per Mind
|
|
|
|
- Each Mind's Huginn daemon binds its own socket, given by `--desk-socket <absolute path>` and
|
|
`--desk-peer-uid <uid>` (both or neither). The ops plan names `/run/huginn-desk/<mind>.sock`, mode
|
|
0660, group `huginn-desk`.
|
|
- The kernel's peer credential is checked before a byte is read. Only the configured uid (Bifrost's)
|
|
is answered; any other peer is closed with nothing read or written. That check is the whole
|
|
authentication: Bifrost vouches for `subject` and `deciders`, and Huginn never sees the member's
|
|
token.
|
|
- Each connection carries one request frame and gets one reply frame, all within 1 second. A
|
|
retry is a new connection. Huginn answers each read from its live image at one snapshot. It keeps
|
|
no cache, and Bifrost needs none: the list's `stamp` (`s<N>`) is the change signal.
|
|
- Huginn serves only its own Mind. A request whose `mind` is another instance is refused
|
|
(`Refused(...)`).
|
|
|
|
## Where forks come from
|
|
|
|
A fork is any question the Mind holds, in any of its campaigns, that is showable. Showable
|
|
(`showable`, `desk_wire.rs`) means at least two options, no blank option text or `depends` line,
|
|
and at least one decider on the call. An in-force question is `open`. A question with any
|
|
resolution in force is `recorded` (a closed card, `ruling` set when an in-force ruling answers it).
|
|
That set includes the questions holding a spec (`huginn blocked`) and the open questions of every
|
|
campaign (`huginn open`). Nothing else in the mind is a fork: follow-ups, findings, research and
|
|
Persona state never reach the desk.
|
|
|
|
The fixtures use the fork id `eureka-substrate:question:desk-listing-authority`, a question Eureka's
|
|
mind holds. The record's answers, notes and `options_hash` are synthetic: the hash is CultLib's
|
|
sample digest.
|
|
|
|
## Framing and envelope
|
|
|
|
```
|
|
frame = u32 big-endian length || message
|
|
message = MessagePack map, CultNet schema v0 (cultnet-rs encode_cultnet_message_to_vec)
|
|
payload = message["payload"], standard base64 of named MessagePack (rmp_serde::to_vec_named)
|
|
```
|
|
|
|
- Request message: `schemaVersion` `cultnet.operation_request.v0`, `serviceId` `huginn.desk`,
|
|
`operation` `forks` (list) or `fork` (read), `payloadSchema` `huginn.desk_request.v1`,
|
|
`payloadEncoding` `messagepack-base64`, `messageId` (at most 256 bytes, echoed back).
|
|
- Response message: `cultnet.operation_response.v0`, the same `serviceId`, `operation` and
|
|
`messageId`, `status` `accepted` or `rejected`, and `sourceRuntimeId` (informational: do not
|
|
compare it). The `payloadSchema` is `huginn.desk_response.v1`, or
|
|
`gamecult.cultnet.operation_failure.v1` when the envelope or payload did not decode.
|
|
- Operation messages (requests, responses, failures) have their keys written in sorted order,
|
|
with absent optionals omitted. The CultNet `Error` message does not follow that: its keys come in
|
|
CultNet's raw order (`schemaVersion`, `error`, `routingHint`, `code`, `details`), and absent
|
|
values are written as nil (fixture `error.response`). Decode by key, never by position.
|
|
- An operation failure carries `diagnostics`, a list holding the failure code (fixture
|
|
`failure.response`). It is absent on every other response.
|
|
- The door also answers `cultnet.schema_catalog_request.v0` with the catalog of the two desk
|
|
schemas (`huginn.desk_request.v1`, `huginn.desk_response.v1`, with their JSON Schemas). No
|
|
fixture pins that reply, because it embeds the schema text.
|
|
- Frame bound: 65536 bytes each way. A reply past it is refused by name, never truncated.
|
|
|
|
## Payloads
|
|
|
|
Rust's externally tagged enums: a one-key map from the variant name to its fields. Every field is
|
|
written; `null` where absent.
|
|
|
|
Request (`huginn.desk_request.v1`):
|
|
|
|
| operation | payload |
|
|
|---|---|
|
|
| `forks` | `{"Forks": {mind, subject, deciders, status, cursor, limit}}`. `status` is `"open"`, `"recorded"` or nil (both); `cursor` is `"c<N>"` (N from 1) or nil; `limit` 0..50, where 0 returns counts and stamp only. |
|
|
| `fork` | `{"Fork": {mind, subject, deciders, fork_id}}` |
|
|
|
|
`deciders` is the Mind's admin-set list for this call: 1 to 64 distinct ids, sent on every call.
|
|
Huginn keeps no copy. `subject` is the verified Bifrost account id of the reader. The reads carry
|
|
no `authority`, and a payload that names it is refused whole.
|
|
|
|
**Account ids.** `subject`, every `deciders` entry and every stored `answerer` are Bifrost account ids:
|
|
`UserAccount.Id` in lowercase D-format, 8-4-4-4-12 lowercase hex (Bifrost renders it in
|
|
`DeskEndpoints.cs` with `accountId.Value.ToString("D")`, and `StartupConfigurationValidator.cs`
|
|
refuses any decider not in that form). That is the same grammar Huginn names for `RequestId`
|
|
(`uuid_text`). These ids are opaque random UUIDs. A Matrix id, an email, a display name or any other
|
|
handle is never one of them. Bifrost enforces the grammar, and so does Huginn's door: `subject` and
|
|
every `deciders` entry are read as `AccountId` (`eureka-pipeline`), and decode refuses any other shape as
|
|
`payload-not-a-request`, naming the field and never quoting the value (fixtures
|
|
`read-mxid-decider.request` and `read-email-subject.request`, each with its `.failure.response`). A
|
|
subject is stored as `answerer` on an answer, and the stored `desk_answer.answerer` is an `AccountId` too,
|
|
so no other shape can enter the mind. The test
|
|
`every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid` pins the grammar. The fixtures use
|
|
synthetic UUIDs.
|
|
|
|
## Account ids by reader
|
|
|
|
A reader is a **decider** when the call's `subject` is in the call's `deciders`, compared byte for
|
|
byte; both values are in the account-id format above. Any other reader is a non-decider. The
|
|
reader is worked out per call and stored nowhere.
|
|
|
|
- **Ruled (Persona, 2026-11-11; follow-up `chat-mind-20261111T150417226Z-t167`):** the door, not
|
|
Bifrost, withholds account ids from a non-decider and returns a count instead. Bifrost cannot do
|
|
it, because the card is opaque to it. A decider sees the ids. The ruling is
|
|
`eureka-substrate:ruling:desk-read-visibility-withheld-ids`.
|
|
- **The shape**, pinned to CultLib commit `d3025d3c19674803ad32eb2335461b8cbc22bd28` (#118 "null-answerer vectors for
|
|
Revision and Retraction", additive over `3a51bbab` #117 "Fork card: decider_count, answerer_count,
|
|
nullable answerer (reader views)", on `af914253` #115 `raised_at`;
|
|
ruling `eureka-substrate:ruling:desk-reader-counts-shape`). The bytes are CultLib's own vectors
|
|
(`contracts/cultnet/governance/fork-card-vectors.json`, `messagePackHex`), vendored unedited in
|
|
`crates/huginn-mind/tests/fixtures/desk-feed/cultlib-d3025d3c/` (its README lists each file and
|
|
its vector name). Huginn's `ForkCard`, `ForkRecord`, `ForkDeskAnswer` and `ForkEntry` decode all
|
|
14 of them and re-encode them byte for byte. Read off those bytes:
|
|
- **Card:** 18 keys, header `DE 00 12` (map16): `schemaVersion` plus 17 fields, in this order:
|
|
`fork_id, title, context, options, recommendation, depends, leans, flag, raised_in, raised_at,
|
|
deciders, decider_count, status, ruling, ruled_from, order, options_hash`. `raised_at` is an
|
|
RFC3339 string or nil, always written. `decider_count` is an integer from 1 to 64 right after
|
|
`deciders`: the length of the call's list, for every reader. An empty `deciders` means the ids
|
|
are withheld from this reader, never "no deciders"; a non-empty list holds exactly
|
|
`decider_count` ids. The card decoder refuses a count outside 1..64 or a non-empty list of
|
|
another length.
|
|
- **Record:** 6 keys, header `86`: `schemaVersion, fork_id, options_hash, counted_answer_id,
|
|
answerer_count, answers`, so `answerer_count` is the fifth key, right before `answers`. It is
|
|
the number of distinct answerers over ALL the fork's stored answers, refusals included, and is
|
|
the same for both readers. In a non-decider's view every answerer is nil, and the count is then
|
|
between 1 and the number of answers (0 with no answers).
|
|
- **Answer:** `gamecult.desk_answer.v0` keeps its 16 keys (header `DE 00 10`); `answerer` is a
|
|
string or nil, nil only in a non-decider's view, with the key kept. The bytes pin a nil `answerer`
|
|
on all four variants, `Counted` (`answer-withheld-answerer`), `Revision`
|
|
(`answer-withheld-answerer-revision`), `Retraction` (`answer-withheld-answerer-retraction`) and
|
|
`Refused` (the withheld entries), beside each variant's string-`answerer` vector. Stored answers
|
|
always carry it, and so does the reply to an answer (`Answered`), which goes to the answerer.
|
|
- **Entry:** header `83` (`schemaVersion, card, record`). The view is read off `deciders`: a
|
|
non-empty `deciders` names every answerer, an empty one names none.
|
|
- `options_hash` does not change (still five members).
|
|
- **Fixtures:** Huginn's own encoders write entry-bearing fixtures for both views:
|
|
`list-open.response` and `read.response` (a decider's; `read.response` has `raised_at` nil),
|
|
`list-open-non-decider.response`, `read-non-decider.response` and `record-withheld`, with the
|
|
requests `list-non-decider.request` and `read-non-decider.request`. A test searches the
|
|
non-decider payloads' bytes for every account id and finds none.
|
|
- **Consumers:** Bifrost branch `heimdall/desk-card-repin` (`f977a43`) vendors Huginn's desk
|
|
fixtures from `79d954c` and needs a re-pin to this change. Bifrost's `DeskDocuments.cs` marks
|
|
`answerer_count` `JsonRequired`; the door now sends it.
|
|
|
|
Response (`huginn.desk_response.v1`):
|
|
|
|
| variant | meaning | Bifrost answers |
|
|
|---|---|---|
|
|
| `{"Forks": {list, as_of}}` | one page, `list` is `gamecult.fork.list.v0` | 200 |
|
|
| `{"Fork": {entry, as_of}}` | one fork, `entry` is `gamecult.fork.entry.v0` | 200 |
|
|
| `{"NotStored": refusal}` | `gamecult.fork.refusal.v0`: `fork_unknown`, or `fork_too_large` | 404 / 422 |
|
|
| `{"Refused": <MindRefusal>}` | a Mind refusal (store fault, wrong instance, a list reply over the cap) | 503 `unavailable` |
|
|
|
|
The `Refused` value is opaque to Bifrost: any `Refused` is a 503. Its shape depends on the
|
|
variant: a variant with fields is a one-key map (`{"Unavailable": {"detail": ...}}`,
|
|
`{"ResponseTooLarge": {"bytes": ..., "limit": ...}}`), and a unit variant is a bare string
|
|
(`"MissingIdentity"`, fixture `refused-unit.response`).
|
|
|
|
**`fork_too_large` has two forms, and both are live on `main`.**
|
|
|
|
- The list form comes from `Mind::desk_forks` when the first entry of a page is alone past the
|
|
page budget: `fork_too_large: fork <fork_id> at order <N> is past the page budget by itself;
|
|
continue with cursor c<N>`, or `...; this is the end of the list` at order 0 (fixture
|
|
`list-too-large.response`).
|
|
- The read form comes from the door (`respond` in `desk_door.rs`, `fork_too_large_read`) when a
|
|
read's whole entry is past the 65536-byte reply cap: `fork_too_large: fork <fork_id> is past the
|
|
response bound by itself`, with no order and no cursor (fixture `read-too-large.response`).
|
|
CultLib `fork-card.md` limitation 13 still says that an over-cap read reaches Bifrost as
|
|
`Refused(ResponseTooLarge)`. That text is stale: Huginn main answers the read form. The refusal
|
|
carries no typed order or fork id. A `ForkTooLarge(ForkId[, Order])` in Bifrost must parse them
|
|
out of the informational `error` text (CultLib calls the text informational), or use the route's
|
|
`fork_id` and the request's cursor instead. The second is the safer choice.
|
|
- Any other reply over the cap, which in practice means a list, is `Refused(ResponseTooLarge)`, a
|
|
503 (fixture `list-response-too-large.response`). A list should not reach it, because pages are
|
|
budgeted below the cap.
|
|
|
|
## Failure means 503 for that Mind only
|
|
|
|
Each of these gives the request 503 `unavailable` with `Retry-After`. Only the Mind addressed is
|
|
affected, because each Mind has its own socket and the client asks per Mind:
|
|
|
|
- the socket is absent, or refuses the connection (`ENOENT`, `ECONNREFUSED`), or the peer closes it
|
|
without a reply frame;
|
|
- no whole reply frame arrives within the deadline;
|
|
- the reply is a CultNet `Error` message (`cultnet.error.v0`, fixture `error.response`), an
|
|
operation failure (fixture `failure.response`), or a `Refused` payload (fixtures
|
|
`unavailable.response`, `list-response-too-large.response`, `refused-unit.response`).
|
|
|
|
An empty list is never served in place of a failure. `open_count: 0` means zero open forks.
|
|
|
|
## Card fields
|
|
|
|
`raised_at` is a required card key whose value is an RFC3339 string or nil (CultLib #115, `af914253`).
|
|
Nil means unknown. A string is the question's first-admission time, which never moves: a UTC `Z` instant at seconds resolution (the form CultLib requires), and nil when the time is unknown, does not parse, or its UTC year is outside 0..9999. It is outside
|
|
`options_hash`, which keeps its five members, so the hash vectors do not change. Huginn derives it
|
|
from the question's admission: the `committed_at` of the receipt that first admitted the question, or
|
|
for an imported question the source mind's `committed_at` (`imported_from.committed_at`). A value that
|
|
does not parse as RFC3339 is served as nil. One known limit: after a campaign has moved twice (A to B
|
|
to C), C sees B's admission time, because an import quotes its source's receipt and B's receipt is B's
|
|
own (follow-up `desk-raised-at-import-chain`).
|
|
|
|
The published schemas (`schemas/cultnet/huginn.desk_response.v1.schema.json`) type `answerer` and `raised_at` as plain `string`, the existing convention for nullable keys here; a consumer validating against CultLib's own schema must treat nil as allowed for both.
|
|
|
|
The card's key order and bytes are pinned by the vendored CultLib vectors (above): `card-operator-fork`
|
|
(`raised_at` a string), `card-raised-at-nil` and `card-withheld-deciders`, and Huginn's own entry-bearing
|
|
fixtures carry it (`list-open.response` a string, `read.response` nil).
|
|
|
|
## Trust boundary: what Huginn sends, what Bifrost does
|
|
|
|
- The peer-uid check is the whole authentication, so the socket must belong to Bifrost's uid alone.
|
|
- Huginn sends each record with every answer's note (fixture `record-whole`, a decider's view) and
|
|
withholds the account ids from a non-decider (above, fixture `record-withheld`). Notes stay whole
|
|
on the wire.
|
|
- Bifrost owns three things: the per-request membership check, the `desk.read` token, and
|
|
stripping notes for a reader who is not a decider (`DeskNotes`). It no longer masks answerer
|
|
ids.
|
|
|
|
## Listing: which forks a reader sees (Huginn's)
|
|
|
|
Huginn owns audience and visibility, at its door. The card has no audience key (CultLib), and
|
|
Bifrost's feed applies no fork filter.
|
|
|
|
- **Default: not listed.** A fork (a showable question) appears in a list or read for a reader who
|
|
is not on the call's `deciders` only while a `desk_listing` document for it is in force. Otherwise
|
|
the list omits it, it counts in neither `open_count` nor `recorded_count`, and a read of it
|
|
answers `fork_unknown`, the same bytes as a fork that does not exist. The counts and the cursor
|
|
are computed over the visible set, which is why the gate is Huginn's: a filter applied after
|
|
paging would make both wrong.
|
|
- **Deciders** see every showable fork whether or not it is listed (ruling
|
|
`eureka-substrate:ruling:desk-deciders-see-all`), because they must see a fork to answer it. The
|
|
door compares the call's `subject` with its `deciders` to tell. The wire does not change. An
|
|
operator or steward who is not on `deciders` sees only listed forks: reads carry no authority, so
|
|
anyone who must see unlisted forks is put on the Mind's decider list in Bifrost's admin.
|
|
- **What a non-decider can still infer.** `order` is the fork's rank among all the Mind's
|
|
questions, and `stamp` is the Mind's head. So a non-decider sees gaps in `order` (some unlisted or
|
|
unshowable questions exist between two listed ones) and sees `stamp` move on any admission. The
|
|
gate accepts this leak: no fork's text, id or count leaves the door. Ranking over the visible set
|
|
only would make `order` depend on the reader and move whenever a fork is listed, which breaks
|
|
CultLib's rule that an order never changes and breaks every held cursor.
|
|
- **The listing record** is kind `desk_listing` (`epiphany.pipeline.desk_listing.v2`), key `<campaign>:desk_listing:<question-label>`,
|
|
fields `campaign`, `listed` (the question's id, in the same campaign) and `reason` (one line: why
|
|
members may read it). It is
|
|
admitted on the Mind's own Admit door only (the desk door refuses one), so its receipt records who listed it and when. Unlisting is a
|
|
`Withdrawn` resolution on it, and relisting withdraws that resolution, which reopens it. Each
|
|
flip is a document with its own receipt, so the history is `huginn history <listing id>`. No flag
|
|
is stored anywhere else.
|
|
- **Who may list or unlist:** the asking Mind herself (ruling
|
|
`eureka-substrate:ruling:desk-listing-asking-mind`). A fork that names a person's matter is listed
|
|
only with that person's say.
|
|
- **CultLib's text disagrees.** `fork-card.md` (Read and List) says no fork is hidden by
|
|
`fork_unknown` and every `desk.read` holder sees every card. That is Libby's to amend for the
|
|
listing gate (follow-up `cultlib-fork-card-listing`).
|
|
- **Open for Heimdall:** if Bifrost wants any part of the listing (an operator override at the
|
|
desk, a per-Mind default, a reason shown to members), that is his decision to raise. Nothing in
|
|
this plan puts listing state in Bifrost.
|
|
|
|
The fixture `list-empty.response` is also what a reader who is not a decider gets from a Mind
|
|
with nothing listed.
|