From 707642bd543ef2ede0231a78d04824e779e36345 Mon Sep 17 00:00:00 2001 From: Eureka Date: Tue, 10 Nov 2026 23:57:27 +0000 Subject: [PATCH] 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 --- crates/huginn-daemon/src/desk_door.rs | 11 + crates/huginn-mind/src/desk.rs | 2 +- crates/huginn-mind/src/desk_wire/reads.rs | 129 +++++++- crates/huginn-mind/src/docs.rs | 5 + .../huginn.desk_request.v1.schema.json | 27 +- .../huginn.desk_response.v1.schema.json | 306 +++++++++++++++++- 6 files changed, 469 insertions(+), 11 deletions(-) diff --git a/crates/huginn-daemon/src/desk_door.rs b/crates/huginn-daemon/src/desk_door.rs index 72d9f38..d5f868f 100644 --- a/crates/huginn-daemon/src/desk_door.rs +++ b/crates/huginn-daemon/src/desk_door.rs @@ -9,6 +9,17 @@ //! 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. //! +//! 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` diff --git a/crates/huginn-mind/src/desk.rs b/crates/huginn-mind/src/desk.rs index 4bdec8e..6c5d975 100644 --- a/crates/huginn-mind/src/desk.rs +++ b/crates/huginn-mind/src/desk.rs @@ -125,7 +125,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 + 'a { +pub(crate) fn answers_of<'a>(docs: &'a Docs<'_>, fork: &'a str) -> impl Iterator + '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, diff --git a/crates/huginn-mind/src/desk_wire/reads.rs b/crates/huginn-mind/src/desk_wire/reads.rs index b327170..0aecca9 100644 --- a/crates/huginn-mind/src/desk_wire/reads.rs +++ b/crates/huginn-mind/src/desk_wire/reads.rs @@ -12,6 +12,8 @@ //! `desk-read-visibility-whole-record`). Nothing is stored. use super::*; +use crate::desk::answers_of; +use crate::desk::options_hash; /// The card document's schema id. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -223,22 +225,135 @@ impl JsonSchema for ForkLimit { } } +/// The card of a question held at the snapshot `docs` reads, shown to the call's `deciders`. +fn card_of(docs: &Docs<'_>, key: &str, question: &PipelineQuestion, deciders: &[Short]) -> Result { + let open = docs.in_force(PipelineKind::Question, key); + // The ruling that closed the fork: the in-force one that answers it (the key order picks if a second ever does). + let ruling = (!open) + .then(|| { + docs.of_kind(PipelineKind::Ruling) + .filter(|(ruling_key, document)| { + matches!(document, PipelineDocument::Ruling(ruling) if ruling.answers.as_ref().is_some_and(|answers| answers.0 == key)) + && docs.in_force(PipelineKind::Ruling, ruling_key) + }) + .map(|(ruling_key, _)| ruling_key.to_string()) + .min() + }) + .flatten(); + Ok(ForkCard { + schema_version: CardSchema::V0, + fork_id: key.into(), + title: question.title.0.clone(), + context: question.question.0.clone(), + options: question.options.iter().map(|option| ForkOption { id: option.label.0.clone(), text: option.text.0.clone() }).collect(), + recommendation: question.recommended.0.clone(), + depends: question.depends.iter().map(|line| line.0.clone()).collect(), + leans: None, + flag: None, + raised_in: question.raised_in.as_ref().map(|raised| ForkRef { kind: format!("{:?}", raised.kind), id: raised.id.0.clone() }), + deciders: deciders.iter().map(|decider| decider.0.clone()).collect(), + status: if open { CardStatus::Open } else { CardStatus::Closed }, + ruling, + ruled_from: Vec::new(), + order: docs.ordinal_of(PipelineKind::Question, key).ok_or_else(unreadable_key)?, + options_hash: options_hash(question).0, + }) +} + +/// The record of a fork: its answers in admission order and the answer that counts at this snapshot. +fn record_of(docs: &Docs<'_>, key: &str, options_hash: &str) -> Result { + let mut stored: Vec<_> = answers_of(docs, key).collect(); + stored.sort_by_key(|(_, answer)| answer.sequence); + let answers = stored.into_iter().map(|(answer_key, answer)| project(answer_key, answer)).collect::, _>>()?; + let counted_answer_id = counted_answer(docs, key).map(|counted| answer_seq(key, &counted).map(n_text).ok_or_else(unreadable_key)).transpose()?; + Ok(ForkRecord { schema_version: RecordSchema::V0, fork_id: key.into(), options_hash: options_hash.into(), counted_answer_id, answers }) +} + +fn entry_of(docs: &Docs<'_>, key: &str, question: &PipelineQuestion, deciders: &[Short]) -> Result { + let card = card_of(docs, key, question, deciders)?; + let record = record_of(docs, key, &card.options_hash)?; + Ok(ForkEntry { schema_version: EntrySchema::V0, card, record }) +} + impl Mind { - /// STUB - pub fn desk_forks(&self, _deciders: &[Short], _status: Option, _cursor: Option, _limit: ForkLimit) -> DeskResponse { - DeskResponse::NotStored(fork_unknown()) + /// One page of the desk's forks, read at one snapshot (`as_of`, also the page's stamp `s`). + /// + /// The showable forks only: a question `showable` refuses (fewer than two options, a blank option + /// or depends line, no decider) is in no count and no page. `open_count` and `recorded_count` cover + /// that whole set whatever `status`, `cursor` and `limit` ask. The page is the forks newest first by + /// `order` (the question's admission ordinal), those with the asked `status` (`None` is both) and + /// an order strictly below the cursor's; `limit` 0 returns no entries. `next_cursor` names the last + /// order returned and is `None` when no further fork matches. Several questions admitted in one + /// commit share an order, and a page that ends between them skips the rest (the contract's cursor + /// has no tie-break). + /// + /// The call is handed the call's `deciders` and no subject: they fill each card's `deciders` and + /// feed the showability gate, and gate nothing else. The record is whole, notes included + /// (ruling `desk-read-visibility-whole-record`: Bifrost applies visibility, Huginn strips nothing). + pub fn desk_forks(&self, deciders: &[Short], status: Option, cursor: Option, limit: ForkLimit) -> DeskResponse { + self.forks_page(deciders, status, cursor, limit).unwrap_or_else(door_refusal) } - /// STUB - pub fn desk_fork(&self, _deciders: &[Short], _fork_id: &str) -> DeskResponse { - DeskResponse::NotStored(fork_unknown()) + /// One fork, as the entry a page carries, read at one snapshot. A fork that is not held, is not a + /// question or is not showable is `fork_unknown`, the answer path's refusal. Whole record, as `desk_forks`. + pub fn desk_fork(&self, deciders: &[Short], fork_id: &str) -> DeskResponse { + self.fork_entry(deciders, fork_id).unwrap_or_else(door_refusal) + } + + fn forks_page(&self, deciders: &[Short], status: Option, cursor: Option, limit: ForkLimit) -> Result { + let image = self.image()?; + let docs = Docs::at(image, image.head); + let mut shown = Vec::new(); + for (key, document) in docs.of_kind(PipelineKind::Question) { + let PipelineDocument::Question(question) = document else { continue }; + if showable(question, deciders) { + let order = docs.ordinal_of(PipelineKind::Question, key).ok_or_else(unreadable_key)?; + shown.push((order, key, question, docs.in_force(PipelineKind::Question, key))); + } + } + // Newest first; the key breaks a tie so that a page is the same page on every read. + shown.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(left.1))); + let open_count = shown.iter().filter(|fork| fork.3).count() as u64; + let recorded_count = shown.len() as u64 - open_count; + let mut matching = shown.iter().filter(|(order, _, _, open)| { + cursor.is_none_or(|cursor| *order < cursor.order()) + && match status { + None => true, + Some(ListStatus::Open) => *open, + Some(ListStatus::Recorded) => !*open, + } + }); + let page: Vec<_> = matching.by_ref().take(limit.get() as usize).collect(); + let next_cursor = if matching.next().is_some() { page.last().and_then(|(order, ..)| ForkCursor::new(*order)) } else { None }; + let forks = page.into_iter().map(|(_, key, question, _)| entry_of(&docs, key, question, deciders)).collect::, _>>()?; + let list = ForkList { + schema_version: ListSchema::V0, + mind: self.instance().0.clone(), + stamp: format!("s{}", image.head), + open_count, + recorded_count, + forks, + next_cursor, + }; + Ok(DeskResponse::Forks { list, as_of: image.head }) + } + + fn fork_entry(&self, deciders: &[Short], fork_id: &str) -> Result { + let image = self.image()?; + let docs = Docs::at(image, image.head); + let Some(PipelineDocument::Question(question)) = docs.in_image(PipelineKind::Question, fork_id) else { + return Ok(DeskResponse::NotStored(fork_unknown())); + }; + if !showable(question, deciders) { + return Ok(DeskResponse::NotStored(fork_unknown())); + } + Ok(DeskResponse::Fork { entry: entry_of(&docs, fork_id, question, deciders)?, as_of: image.head }) } } #[cfg(test)] mod tests { use super::*; - use crate::desk::options_hash; use crate::envelope::{decode_desk_request, encode_desk_request, encode_desk_response}; use crate::fixtures::*; use crate::store::test_stores::MemoryStore; diff --git a/crates/huginn-mind/src/docs.rs b/crates/huginn-mind/src/docs.rs index ec5c99d..f8a7278 100644 --- a/crates/huginn-mind/src/docs.rs +++ b/crates/huginn-mind/src/docs.rs @@ -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 { + 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)) } diff --git a/schemas/cultnet/huginn.desk_request.v1.schema.json b/schemas/cultnet/huginn.desk_request.v1.schema.json index caaa6a6..d250def 100644 --- a/schemas/cultnet/huginn.desk_request.v1.schema.json +++ b/schemas/cultnet/huginn.desk_request.v1.schema.json @@ -1,7 +1,7 @@ { "$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 the set refuses\nthe whole payload. The reads answer in follow-up desk-wire-reads.", + "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", @@ -52,6 +52,10 @@ "Forks": { "type": "object", "properties": { + "cursor": { + "type": "string", + "pattern": "^c[1-9][0-9]*$" + }, "deciders": { "type": "array", "items": { @@ -61,9 +65,20 @@ "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" } @@ -72,7 +87,10 @@ "required": [ "mind", "subject", - "deciders" + "deciders", + "status", + "cursor", + "limit" ] } }, @@ -185,6 +203,11 @@ "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", diff --git a/schemas/cultnet/huginn.desk_response.v1.schema.json b/schemas/cultnet/huginn.desk_response.v1.schema.json index 074acc0..87ff74a 100644 --- a/schemas/cultnet/huginn.desk_response.v1.schema.json +++ b/schemas/cultnet/huginn.desk_response.v1.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "DeskResponse", - "description": "What the door answers one desk request with. `Answered` is a stored answer, refused ones\nincluded (a stale fork, a non-decider and the rest are stored), fresh or replayed alike.\n`NotStored` is a request that stored nothing. `Refused` is any other Mind refusal; an\n`Unavailable` is Bifrost's to turn into its 503, and Huginn writes no document for it.", + "description": "What the door answers one desk request with. `Answered` is a stored answer, refused ones\nincluded (a stale fork, a non-decider and the rest are stored), fresh or replayed alike.\n`Forks` and `Fork` are the reads: one page of entries, or one entry, with the head they were\nall read at. `NotStored` is a request that stored nothing (an unknown fork included).\n`Refused` is any other Mind refusal; an `Unavailable` is Bifrost's to turn into its 503, and\nHuginn writes no document for it.\n\nThe trust boundary of the reads (ruling `desk-read-visibility-whole-record`): `Forks` and `Fork`\ncarry the WHOLE record, every answer's note included, to whatever the peer-checked socket\ncarries. Huginn strips nothing and gates nothing; Bifrost applies visibility at its door (a\nstripped read serves `note: null`, CultLib `fork-card.md`). Huginn must therefore be reachable\nonly by Bifrost's peer-checked socket: the peer-uid check IS the whole authentication, and a\nsecond reader of that socket is a note leak.", "oneOf": [ { "type": "object", @@ -35,6 +35,58 @@ "Answered" ] }, + { + "type": "object", + "properties": { + "Forks": { + "type": "object", + "properties": { + "as_of": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "list": { + "$ref": "#/$defs/ForkList" + } + }, + "required": [ + "list", + "as_of" + ] + } + }, + "additionalProperties": false, + "required": [ + "Forks" + ] + }, + { + "type": "object", + "properties": { + "Fork": { + "type": "object", + "properties": { + "as_of": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "entry": { + "$ref": "#/$defs/ForkEntry" + } + }, + "required": [ + "entry", + "as_of" + ] + } + }, + "additionalProperties": false, + "required": [ + "Fork" + ] + }, { "type": "object", "properties": { @@ -68,6 +120,21 @@ "gamecult.desk_answer.v0" ] }, + "CardSchema": { + "description": "The card document's schema id.", + "type": "string", + "enum": [ + "gamecult.fork.card.v0" + ] + }, + "CardStatus": { + "description": "Whether a ruling closed the fork.", + "type": "string", + "enum": [ + "open", + "closed" + ] + }, "DeskAuthority": { "type": "string", "enum": [ @@ -76,6 +143,114 @@ "Member" ] }, + "EntrySchema": { + "description": "The entry document's schema id.", + "type": "string", + "enum": [ + "gamecult.fork.entry.v0" + ] + }, + "ForkCard": { + "description": "`gamecult.fork.card.v0`: closed, her field order.", + "type": "object", + "properties": { + "context": { + "type": "string" + }, + "deciders": { + "type": "array", + "items": { + "type": "string" + } + }, + "depends": { + "type": "array", + "items": { + "type": "string" + } + }, + "flag": { + "type": "string" + }, + "fork_id": { + "type": "string" + }, + "leans": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "$ref": "#/$defs/ForkOption" + } + }, + "options_hash": { + "type": "string" + }, + "order": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "raised_in": { + "description": "A typed reference, as the question holds it (`raised_in`).", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "kind", + "id" + ] + }, + "recommendation": { + "type": "string" + }, + "ruled_from": { + "type": "array", + "items": { + "type": "string" + } + }, + "ruling": { + "type": "string" + }, + "schemaVersion": { + "$ref": "#/$defs/CardSchema" + }, + "status": { + "$ref": "#/$defs/CardStatus" + }, + "title": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schemaVersion", + "fork_id", + "title", + "context", + "options", + "recommendation", + "depends", + "leans", + "flag", + "raised_in", + "deciders", + "status", + "ruling", + "ruled_from", + "order", + "options_hash" + ] + }, "ForkDeskAnswer": { "description": "`gamecult.desk_answer.v0`: one stored answer as the desk shows it, her field order,\n`request_id` last. Every value is a stored fact; `answer_id`, `of` and `retracts` are the\nrecord keys in her `n` spelling.", "type": "object", @@ -158,6 +333,121 @@ "request_id" ] }, + "ForkEntry": { + "description": "`gamecult.fork.entry.v0`: a card and its record.", + "type": "object", + "properties": { + "card": { + "$ref": "#/$defs/ForkCard" + }, + "record": { + "$ref": "#/$defs/ForkRecord" + }, + "schemaVersion": { + "$ref": "#/$defs/EntrySchema" + } + }, + "additionalProperties": false, + "required": [ + "schemaVersion", + "card", + "record" + ] + }, + "ForkList": { + "description": "`gamecult.fork.list.v0`: a page of entries, the counts over the showable set and the stamp.", + "type": "object", + "properties": { + "forks": { + "type": "array", + "items": { + "$ref": "#/$defs/ForkEntry" + } + }, + "mind": { + "type": "string" + }, + "next_cursor": { + "type": "string", + "pattern": "^c[1-9][0-9]*$" + }, + "open_count": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "recorded_count": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "schemaVersion": { + "$ref": "#/$defs/ListSchema" + }, + "stamp": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "schemaVersion", + "mind", + "stamp", + "open_count", + "recorded_count", + "forks", + "next_cursor" + ] + }, + "ForkOption": { + "description": "One shown option.", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "id", + "text" + ] + }, + "ForkRecord": { + "description": "`gamecult.fork.record.v0`: the answers in admission order and the derived counted answer.", + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/$defs/ForkDeskAnswer" + } + }, + "counted_answer_id": { + "type": "string" + }, + "fork_id": { + "type": "string" + }, + "options_hash": { + "type": "string" + }, + "schemaVersion": { + "$ref": "#/$defs/RecordSchema" + } + }, + "additionalProperties": false, + "required": [ + "schemaVersion", + "fork_id", + "options_hash", + "counted_answer_id", + "answers" + ] + }, "ForkRefusal": { "description": "`gamecult.fork.refusal.v0`. `error` is a fixed sentence, never an echo of what was sent;\nfor `body_invalid` it names the field.", "type": "object", @@ -188,6 +478,13 @@ "request_id_conflict" ] }, + "ListSchema": { + "description": "The list document's schema id.", + "type": "string", + "enum": [ + "gamecult.fork.list.v0" + ] + }, "MindRefusal": { "description": "Every way a mind refuses. `Document` wraps the leaf's four (bounds,\nformats, key identity, foreign type); the rest are the organ's: store\nidentity, epoch, ownership, batch shape, references, and every cross-field\nand cross-document rule of admission.\n\nThe two sequenced kinds refuse in the same pair of shapes: an\n`OutOfSequence` when the writer's `sequence` is not the previous plus one,\nand `AlreadyResolved`/`AlreadyStewarded` when a record of that scope is\nstill in force.", "oneOf": [ @@ -1497,6 +1794,13 @@ } ] }, + "RecordSchema": { + "description": "The record document's schema id.", + "type": "string", + "enum": [ + "gamecult.fork.record.v0" + ] + }, "RefusalSchema": { "description": "The refusal document's schema id.", "type": "string",