From 3f74cdc869681e7894fede3f7476930e825c0e0b Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 20:56:27 +0000 Subject: [PATCH] Desk door: the listing gate and the account-id grammar at decode A non-decider reads a fork only while the Mind's desk_listing for it is in force; a decider sees every showable fork. Counts, pages and cursors are over the visible set; a hidden fork reads as a missing one (same bytes). New kind desk_listing (epiphany.pipeline.desk_listing.v2, key :desk_listing:; unlist = Withdrawn resolution, relist = withdraw that), new citation role Listed (ALL is 27), admitted on the Admit door only (the desk door refuses one). subject and every deciders entry are AccountId (lowercase D-format UUID) at decode, refused as payload-not-a-request naming the field and never the value; the stored desk_answer.answerer is an AccountId. The longest kind name is now desk_listing, so the resolution local bound is 112. Tests move to account ids (fixtures::account); refused-request fixtures and their failure frames; all published schemas, the golden envelopes and the catalog index regenerated. Co-Authored-By: Claude Sonnet 5.5 --- crates/eureka-pipeline/golden/envelopes.txt | 3 +- crates/eureka-pipeline/src/lib.rs | 38 +- crates/eureka-state/src/bin/huginn/schema.rs | 2 +- crates/eureka-state/tests/cli.rs | 4 +- crates/huginn-daemon/src/daemon.rs | 17 +- crates/huginn-daemon/src/desk_door_tests.rs | 36 +- crates/huginn-daemon/tests/desk_door.rs | 32 +- crates/huginn-mind/src/admission.rs | 26 +- crates/huginn-mind/src/desk.rs | 23 +- crates/huginn-mind/src/desk_wire.rs | 87 +++-- crates/huginn-mind/src/desk_wire/reads.rs | 339 ++++++++++++++---- crates/huginn-mind/src/docs.rs | 12 +- crates/huginn-mind/src/envelope.rs | 17 +- crates/huginn-mind/src/fixtures.rs | 15 + crates/huginn-mind/src/index.rs | 4 +- crates/huginn-mind/src/query.rs | 12 +- crates/huginn-mind/src/rows.rs | 10 +- .../huginn-mind/tests/desk_feed_fixtures.rs | 58 ++- .../tests/fixtures/desk-feed/README.md | 10 +- ...d-email-subject.failure.response.frame.bin | Bin 0 -> 375 bytes ...d-email-subject.failure.response.frame.hex | 1 + ...email-subject.failure.response.payload.hex | 1 + ...l-subject.failure.response.payload.msgpack | 1 + .../read-email-subject.request.frame.bin | Bin 0 -> 433 bytes .../read-email-subject.request.frame.hex | 1 + .../read-email-subject.request.payload.hex | 1 + ...read-email-subject.request.payload.msgpack | 1 + ...ad-mxid-decider.failure.response.frame.bin | Bin 0 -> 379 bytes ...ad-mxid-decider.failure.response.frame.hex | 1 + ...-mxid-decider.failure.response.payload.hex | 1 + ...d-decider.failure.response.payload.msgpack | 1 + .../read-mxid-decider.request.frame.bin | Bin 0 -> 442 bytes .../read-mxid-decider.request.frame.hex | 1 + .../read-mxid-decider.request.payload.hex | 1 + .../read-mxid-decider.request.payload.msgpack | 1 + docs/desk-feed-door.md | 29 +- ...iphany.pipeline.desk_answer.v2.schema.json | 8 +- ...phany.pipeline.desk_listing.v2.schema.json | 40 +++ .../epiphany.pipeline.finding.v2.schema.json | 3 +- ...epiphany.pipeline.follow_up.v2.schema.json | 3 +- .../epiphany.pipeline.idea.v2.schema.json | 3 +- .../epiphany.pipeline.memory.v2.schema.json | 3 +- .../epiphany.pipeline.question.v2.schema.json | 3 +- .../epiphany.pipeline.reply.v2.schema.json | 3 +- .../epiphany.pipeline.research.v2.schema.json | 3 +- ...piphany.pipeline.resolution.v2.schema.json | 3 +- .../epiphany.pipeline.ruling.v2.schema.json | 3 +- .../epiphany.pipeline.run.v2.schema.json | 3 +- .../epiphany.pipeline.thought.v2.schema.json | 3 +- .../huginn.desk_request.v1.schema.json | 25 +- .../huginn.desk_response.v1.schema.json | 3 +- .../huginn.mind_request.v1.schema.json | 47 ++- .../huginn.mind_response.v1.schema.json | 75 +++- schemas/cultnet/index.json | 11 + 54 files changed, 789 insertions(+), 239 deletions(-) create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.frame.bin create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.frame.hex create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.payload.hex create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.payload.msgpack create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.frame.bin create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.frame.hex create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.payload.hex create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.payload.msgpack create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.frame.bin create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.frame.hex create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.payload.hex create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.payload.msgpack create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.frame.bin create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.frame.hex create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.payload.hex create mode 100644 crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.payload.msgpack create mode 100644 schemas/cultnet/epiphany.pipeline.desk_listing.v2.schema.json diff --git a/crates/eureka-pipeline/golden/envelopes.txt b/crates/eureka-pipeline/golden/envelopes.txt index d1b1489..ea114d4 100644 --- a/crates/eureka-pipeline/golden/envelopes.txt +++ b/crates/eureka-pipeline/golden/envelopes.txt @@ -32,7 +32,8 @@ sample.research eureka-state:research:p1.scope epiphany.pipeline.research.v2 Epi sample.idea eureka-state:idea:p1.i1 epiphany.pipeline.idea.v2 EpiphanyPipelineIdeaDocument 9189a863616d706169676eac657572656b612d7374617465a470617373a27031a56c6162656ca26931a57469746c65b24f6e65206e6f74652070657220636c61696da469646561d9204b65657020612070617373206173206d616e7920736d616c6c206e6f7465732ea974726164656f666673b74d6f726520646f63756d656e747320746f206c6973742ea872657374735f6f6e9182a46b696e64a85265736561726368a26964be657572656b612d73746174653a72657365617263683a70312e73636f7065a6746f776172649182a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131ab70726f706f7365645f6f6eaa323032362d30392d3135 sample.reply yggdrasil:reply:mind-2026-10-09-1-r1 epiphany.pipeline.reply.v2 EpiphanyPipelineReplyDocument 9185a8696e7374616e6365a979676764726173696ca56c6162656cb46d696e642d323032362d31302d30392d312d7231a372756e82a46b696e64a352756ea26964bf79676764726173696c3a72756e3a6d696e642d323032362d31302d30392d31a7616e73776572739182a46b696e64a8466f6c6c6f775570a26964d923657572656b612d73746174653a666f6c6c6f775f75703a61736b2d7468652d73656c66a474657874d93054686520736861646572207061737320697320646f6e653b2074686520736f756e647320617265204c6962627927732e sample.thought yggdrasil:thought:t1793000000 epiphany.pipeline.thought.v2 EpiphanyPipelineThoughtDocument 91de0014a8696e7374616e6365a979676764726173696ca56c6162656cab7431373933303030303030a474657874d93d54686520736f756e647320617265204c6962627927733b2074686520736861646572732077657265206e6576657220746865206861726420706172742ea572656164739182a46b696e64a64d656d6f7279a26964d92879676764726173696c3a6d656d6f72793a323032362d31302d3331743034313230302d74302d6d30af706572736f6e615f72656365697074b06d696e642d636f6d6d69742d30613162a86964656e74697479be457572656b612c207368652f6865722c20706c61696e2d73706f6b656e2ea7636f6e74657874d92056616c7565733a20636f686572656e6365206f7665722076656c6f636974792ea87374696d756c7573bd4c696262792061736b65642061626f75742074686520736f756e64732eaf70726f6a6563746f725f6d6f64656cb070726f6a6563746f723a6c6174657374aa70726f6a656374696f6ed92949206b656570207475726e696e67206f7665722077686f20686f6c64732074686520736f756e64732eaa626173655f6d6f64656cab626173653a6c6174657374b474656d70657261747572655f7065726d696c6c65cd03e8ae6d696e5f705f7065726d696c6c6532aa6d61785f746f6b656e73cca0aa63616e6469646174657392d93d54686520736f756e647320617265204c6962627927733b2074686520736861646572732077657265206e6576657220746865206861726420706172742eaf41207365636f6e642064726966742eb06e6561726573745f7065726d696c6c6592cd0136cd0366a663686f73656e00ac70726f6a6563746f725f6d73cd1068ac636f6e74696e7565725f6d73cd238cae70726f6d70745f76657273696f6ebd67686f73746c696768743d633433656235343b20776f7264733d313830 -sample.desk_answer eureka-state:desk_answer:Q1.n1 epiphany.pipeline.desk_answer.v2 EpiphanyPipelineDeskAnswerDocument 918ea863616d706169676eac657572656b612d7374617465a4666f726bb8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501ac6f7074696f6e735f68617368d94063636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363a663686f696365a161a46e6f7465b853686172652069742c207769746820746865206d61726b2eab616e7377657265645f6174b4323032362d31312d31305431353a30303a30305aab72656365697665645f6174b4323032362d31312d31305431353a30303a30335aaf636c61696d5f696e5f667574757265c2aa726571756573745f6964d92436663163306135322d336237652d346431612d396335382d306532643761346239663133ab626f64795f736861323536d94064646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464a8616e737765726572a6757365722d37a9617574686f72697479a64d656d626572a76f7574636f6d6581a85265766973696f6e81a26f66be657572656b612d73746174653a6465736b5f616e737765723a51312e6e31 +sample.desk_answer eureka-state:desk_answer:Q1.n1 epiphany.pipeline.desk_answer.v2 EpiphanyPipelineDeskAnswerDocument 918ea863616d706169676eac657572656b612d7374617465a4666f726bb8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501ac6f7074696f6e735f68617368d94063636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363a663686f696365a161a46e6f7465b853686172652069742c207769746820746865206d61726b2eab616e7377657265645f6174b4323032362d31312d31305431353a30303a30305aab72656365697665645f6174b4323032362d31312d31305431353a30303a30335aaf636c61696d5f696e5f667574757265c2aa726571756573745f6964d92436663163306135322d336237652d346431612d396335382d306532643761346239663133ab626f64795f736861323536d94064646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464a8616e737765726572d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a9617574686f72697479a64d656d626572a76f7574636f6d6581a85265766973696f6e81a26f66be657572656b612d73746174653a6465736b5f616e737765723a51312e6e31 +sample.desk_listing eureka-state:desk_listing:Q1 epiphany.pipeline.desk_listing.v2 EpiphanyPipelineDeskListingDocument 9183a863616d706169676eac657572656b612d7374617465a66c6973746564b8657572656b612d73746174653a7175657374696f6e3a5131a6726561736f6ed9324d656d62657273206d617920726561642077687920746865204d696e64206c6973747320697473206f776e20666f726b732e resolution.superseded eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581aa5375706572736564656481a262799282a46b696e64a652756c696e67a26964b6657572656b612d73746174653a72756c696e673a523882a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135 resolution.fixed-by-none eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581a5466978656482a6636f6d6d6974a83566393832323864a26279c0a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135 resolution.fixed-by-some eureka-state:resolution:question.Q1.n1 epiphany.pipeline.resolution.v2 EpiphanyPipelineResolutionDocument 9185a77375626a65637482a46b696e64a85175657374696f6ea26964b8657572656b612d73746174653a7175657374696f6e3a5131a873657175656e636501a76f7574636f6d6581a5466978656482a6636f6d6d6974a83566393832323864a2627982a46b696e64a94375745265706f7274a26964d921657572656b612d73746174653a6375745f7265706f72743a6375742d33612e6831a9726174696f6e616c65a852756c656420412eab7265736f6c7665645f6f6eaa323032362d30392d3135 diff --git a/crates/eureka-pipeline/src/lib.rs b/crates/eureka-pipeline/src/lib.rs index 08b0bf2..1ff8070 100644 --- a/crates/eureka-pipeline/src/lib.rs +++ b/crates/eureka-pipeline/src/lib.rs @@ -316,6 +316,10 @@ bounded_text! { Words = 4000, words_text; /// A client's retry token: a lowercase D-format UUID. RequestId = 36, uuid_text; + /// A Bifrost account id: a lowercase D-format UUID (`UserAccount.Id`). A Matrix id, an email or a + /// handle is never one. + #[schemars(extend("pattern" = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"))] + AccountId = 36, uuid_text; /// A key label: `[A-Za-z0-9_-]{1,64}`. Label = 64, label_text; /// A campaign slug or key segment: label parts joined by `.`. @@ -885,8 +889,13 @@ value_types! { pub struct PipelineDeskAnswer { campaign: Slug, fork: Short, sequence: u32, options_hash: Sha256Hex, choice: Option, note: Option, answered_at: Short, received_at: Short, claim_in_future: bool, request_id: RequestId, body_sha256: Sha256Hex, - answerer: Short, authority: DeskAuthority, outcome: DeskOutcome, + answerer: AccountId, authority: DeskAuthority, outcome: DeskOutcome, } + /// A fork the Mind lists: members who are not on a call's decider list may read the question + /// while this document is in force (unlist = a `Withdrawn` resolution on it; relist = withdraw + /// that). One per question, keyed by the question's label; the receipt says who listed it and + /// when, `reason` why members may read it. Admitted on the Mind's own door only, never the desk's. + pub struct PipelineDeskListing { campaign: Slug, listed: Short, reason: Line } pub struct PipelineVerdict { campaign: Slug, cut_report: Short, pass: u32, range: CommitRange, claims: Vec[64] } pub struct PipelineFinding { campaign: Slug, verdict: Short, label: Label, range: CommitRange, confidence: FindingConfidence, @@ -1425,6 +1434,16 @@ pipeline_kinds! { "epiphany.pipeline.thought.v2", "EpiphanyPipelineThoughtDocument"; DeskAnswer(PipelineDeskAnswer) => EpiphanyPipelineDeskAnswerDocument, "desk_answer", "epiphany.pipeline.desk_answer.v2", "EpiphanyPipelineDeskAnswerDocument"; + DeskListing(PipelineDeskListing) => EpiphanyPipelineDeskListingDocument, "desk_listing", + "epiphany.pipeline.desk_listing.v2", "EpiphanyPipelineDeskListingDocument"; +} + +impl AccountId { + /// Whether this is a lowercase D-format UUID. For the callers outside the crate, which have no way + /// to reach `Bounded`; on the pattern of `Slug::validate_slug`. + pub fn validate_account(&self) -> Result<(), PipelineRefusal> { + self.validate("account") + } } /// Parses a full document id: the grammar read backwards. A key and an id are @@ -1516,7 +1535,7 @@ const SUBJECT_LOCAL_MAX: usize = 64; /// The longest kind name a resolution can name as its subject (`stewardship`), /// checked against `PipelineKind::ALL` by a test. -const LONGEST_KIND_NAME: usize = "stewardship".len(); +const LONGEST_KIND_NAME: usize = "desk_listing".len(); /// A `.n` part at `u32::MAX`. const SEQUENCE_PART_MAX: usize = ".n".len() + "4294967295".len(); @@ -1651,6 +1670,10 @@ pub fn pipeline_key(document: &PipelineDocument) -> Result { + let label = parent_local("desk_listing.listed", &value.listed.0, &value.campaign.0, PipelineKind::Question)?; + (campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[label])?) + } D::Verdict(value) => { let report = parent_local("verdict.cut_report", &value.cut_report.0, &value.campaign.0, PipelineKind::CutReport)?; let cut = parent_cut("verdict.cut_report", report, 'h')?; @@ -1927,9 +1950,12 @@ mod tests { campaign: slug(CAMPAIGN), fork: id("question", "Q1"), sequence: 1, options_hash: Sha256Hex("c".repeat(64)), choice: Some(s("a")), note: Some("Share it, with the mark.".into()), answered_at: s("2026-11-10T15:00:00Z"), received_at: s("2026-11-10T15:00:03Z"), claim_in_future: false, request_id: RequestId("6f1c0a52-3b7e-4d1a-9c58-0e2d7a4b9f13".into()), body_sha256: Sha256Hex("d".repeat(64)), - answerer: s("user-7"), authority: DeskAuthority::Member, + answerer: AccountId("5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f".into()), authority: DeskAuthority::Member, outcome: DeskOutcome::Revision { of: id("desk_answer", "Q1.n1") }, }), format!("{CAMPAIGN}:desk_answer:Q1.n1")), + (D::DeskListing(PipelineDeskListing { + campaign: slug(CAMPAIGN), listed: id("question", "Q1"), reason: "Members may read why the Mind lists its own forks.".into(), + }), format!("{CAMPAIGN}:desk_listing:Q1")), ] } @@ -3587,7 +3613,7 @@ mod tests { ); } assert_eq!(widest, RESOLUTION_LOCAL_MAX, "the bound is exactly the widest depth-two chain"); - assert_eq!(RESOLUTION_LOCAL_MAX, 111); + assert_eq!(RESOLUTION_LOCAL_MAX, 112); } #[test] @@ -4109,7 +4135,7 @@ mod tests { }; let document_schema = serde_json::to_value(schemars::schema_for!(PipelineDocument))?; let variants = document_schema["oneOf"].as_array().expect("the document schema is a oneOf"); - assert_eq!(PipelineKind::ALL.len(), 21, "twenty-one kinds"); + assert_eq!(PipelineKind::ALL.len(), 22, "twenty-two kinds"); assert_eq!( variants.iter().map(tag).collect::>(), PipelineKind::ALL.iter().map(|kind| kind.name().to_owned()).collect::>(), @@ -4639,7 +4665,7 @@ mod tests { } fn desk_answer_sample() -> PipelineDeskAnswer { - let PipelineDocument::DeskAnswer(answer) = samples().pop().unwrap().0 else { unreachable!() }; + let PipelineDocument::DeskAnswer(answer) = samples().remove(20).0 else { unreachable!() }; answer } diff --git a/crates/eureka-state/src/bin/huginn/schema.rs b/crates/eureka-state/src/bin/huginn/schema.rs index 83b270b..209c564 100644 --- a/crates/eureka-state/src/bin/huginn/schema.rs +++ b/crates/eureka-state/src/bin/huginn/schema.rs @@ -20,7 +20,7 @@ macro_rules! kinds { kinds!( campaign, target, question, ruling, cut_spec, cut_report, verdict, finding, follow_up, resolution, instance, stewardship, - hand_off, run, pulse, memory, research, idea, reply, thought, desk_answer, + hand_off, run, pulse, memory, research, idea, reply, thought, desk_answer, desk_listing, ); /// The schema named, or the names when none is: the exit code is 2 for a name diff --git a/crates/eureka-state/tests/cli.rs b/crates/eureka-state/tests/cli.rs index b0a4614..ad9ea32 100644 --- a/crates/eureka-state/tests/cli.rs +++ b/crates/eureka-state/tests/cli.rs @@ -1145,12 +1145,12 @@ fn schema_prints_the_published_files() { let names = run(&[], &["schema"], None); assert_eq!(names.code, 0); let names: Vec<&str> = names.out.lines().collect(); - assert_eq!(names.len(), 22, "{names:?}"); + assert_eq!(names.len(), 23, "{names:?}"); assert_eq!(names[0], "campaign"); assert_eq!(names[14], "pulse"); assert_eq!(names[15], "memory"); assert_eq!((names[16], names[17]), ("research", "idea")); - assert_eq!((names[18], names[19], names[20], names[21]), ("reply", "thought", "desk_answer", "selection")); + assert_eq!((names[18], names[19], names[20], names[21], names[22]), ("reply", "thought", "desk_answer", "desk_listing", "selection")); for kind in PipelineKind::ALL { assert!(names.contains(&kind.name()), "{}", kind.name()); } diff --git a/crates/huginn-daemon/src/daemon.rs b/crates/huginn-daemon/src/daemon.rs index eed809c..ec0fbfc 100644 --- a/crates/huginn-daemon/src/daemon.rs +++ b/crates/huginn-daemon/src/daemon.rs @@ -6,7 +6,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::eureka_pipeline::{AccountId, PipelineRef, Short, Slug}; use huginn_mind::wire::{ACTIVITY_REPOS_MAX, HuginnMindRequest, HuginnMindResponse, IndexStatus}; use huginn_mind::{ChatHits, ChatQuery, ChatWindow, ChatWindowQuery, Members, Mind, MindRefusal, MindStore, PipelineAdmissionOutcome, SemanticQuery, SourceHits, SourceQuery}; @@ -112,6 +112,15 @@ impl Handled { /// What the log says of the hits the mind held no document for: how many, and /// the first few by name (a restored mind can leave hundreds), or nothing when /// the mind held them all. +/// The door checks an account id; the Mind's desk calls take it as the text it is. +fn short_id(id: AccountId) -> Short { + Short(id.0) +} + +fn short_ids(ids: &[AccountId]) -> Vec { + ids.iter().map(|id| Short(id.0.clone())).collect() +} + fn dropped_note(dropped: &[PipelineRef]) -> Option { if dropped.is_empty() { return None; @@ -271,10 +280,10 @@ impl> Daemon { } match request { DeskRequest::Answer { subject, authority, deciders, request, .. } => { - self.mind.desk_answer(&deciders, subject, authority, request, now) + self.mind.desk_answer(&short_ids(&deciders), short_id(subject), authority, request, now) } - DeskRequest::Forks { subject, deciders, status, cursor, limit, .. } => self.mind.desk_forks(&subject, &deciders, status, cursor, limit), - DeskRequest::Fork { subject, deciders, fork_id, .. } => self.mind.desk_fork(&subject, &deciders, &fork_id), + DeskRequest::Forks { subject, deciders, status, cursor, limit, .. } => self.mind.desk_forks(&short_id(subject), &short_ids(&deciders), status, cursor, limit), + DeskRequest::Fork { subject, deciders, fork_id, .. } => self.mind.desk_fork(&short_id(subject), &short_ids(&deciders), &fork_id), } } diff --git a/crates/huginn-daemon/src/desk_door_tests.rs b/crates/huginn-daemon/src/desk_door_tests.rs index 97dc8c5..c5a7225 100644 --- a/crates/huginn-daemon/src/desk_door_tests.rs +++ b/crates/huginn-daemon/src/desk_door_tests.rs @@ -85,20 +85,26 @@ fn wire(hash: &str, request_id: &str) -> ForkAnswerRequest { } } +/// A test's name for someone as the Bifrost account id the door requires: a deterministic lowercase D-format UUID. +fn acct(name: &str) -> huginn_mind::eureka_pipeline::AccountId { + let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)); + huginn_mind::eureka_pipeline::AccountId(format!("{:08x}-{:04x}-4000-8000-{:012x}", hash >> 32, (hash >> 16) & 0xffff, hash & 0xffff_ffff_ffff)) +} + const R1: &str = "00000000-0000-4000-8000-000000000001"; const R2: &str = "00000000-0000-4000-8000-000000000002"; const R3: &str = "00000000-0000-4000-8000-000000000003"; fn answer(who: &str, authority: DeskAuthority, deciders: &[&str], request: ForkAnswerRequest) -> DeskRequest { - DeskRequest::Answer { mind: slug(INSTANCE), subject: short(who), authority, deciders: deciders.iter().map(|d| short(d)).collect(), request } + DeskRequest::Answer { mind: slug(INSTANCE), subject: acct(who), authority, deciders: deciders.iter().map(|d| acct(d)).collect(), request } } /// A list read by `who` against `deciders`. fn forks(who: &str, deciders: &[&str], status: Option, cursor: Option, limit: u8) -> DeskRequest { DeskRequest::Forks { mind: slug(INSTANCE), - subject: short(who), - deciders: deciders.iter().map(|d| short(d)).collect(), + subject: acct(who), + deciders: deciders.iter().map(|d| acct(d)).collect(), status, cursor, limit: ForkLimit::new(limit).unwrap(), @@ -110,7 +116,7 @@ fn forks_all(deciders: &[&str]) -> DeskRequest { } fn fork_read(who: &str, deciders: &[&str], fork_id: &str) -> DeskRequest { - DeskRequest::Fork { mind: slug(INSTANCE), subject: short(who), deciders: deciders.iter().map(|d| short(d)).collect(), fork_id: fork_id.into() } + DeskRequest::Fork { mind: slug(INSTANCE), subject: acct(who), deciders: deciders.iter().map(|d| acct(d)).collect(), fork_id: fork_id.into() } } fn page_of(response: DeskResponse) -> (ForkList, u64) { @@ -567,7 +573,7 @@ mod door_smoke { claim_in_future: false, request_id: RequestId(R1.into()), body_sha256: Sha256Hex("0".repeat(64)), - answerer: short("x"), + answerer: acct("x"), authority: DeskAuthority::Operator, outcome: DeskOutcome::Counted, }); @@ -801,7 +807,7 @@ mod door_smoke { assert_eq!((listed_at, read_at), (before.1 as u64, before.1 as u64), "both read at the head the mind held"); assert_eq!(list.stamp, format!("s{listed_at}")); assert_eq!(list.forks[0], entry); - assert_eq!((entry.card.fork_id.as_str(), entry.card.options_hash.as_str(), entry.card.deciders.as_slice()), (FORK, hash.as_str(), ["x".to_string(), "y".to_string()].as_slice())); + assert_eq!((entry.card.fork_id.as_str(), entry.card.options_hash.as_str(), entry.card.deciders.as_slice()), (FORK, hash.as_str(), [acct("x").0, acct("y").0].as_slice())); assert_eq!(rig.state(), before, "a read stores nothing"); assert!(rig.index.seen.lock().unwrap().is_empty()); // An unknown fork is the answer path's refusal. @@ -864,11 +870,19 @@ mod door_smoke { let note = "Keep it local: the author's call."; rig.serving(|path| counted(reply_of(path, "m-1", &DeskRequest::Answer { mind: slug(INSTANCE), - subject: short("y"), + subject: acct("y"), authority: DeskAuthority::Member, - deciders: vec![short("y")], + deciders: vec![acct("y")], request: noted(&hash, R1, note), }))); + // Unlisted: the outsider is shown nothing at all, and a read is `fork_unknown`. + let (hidden, unknown) = rig.serving(|path| (reply_of(path, "m-1", &forks("outsider", &["x"], None, None, 50)), reply_of(path, "m-1b", &fork_read("outsider", &["x"], FORK)))); + assert_eq!(page_of(hidden).0.forks.len(), 0); + assert!(matches!(unknown, DeskResponse::NotStored(_)), "{unknown:?}"); + // The Mind lists it, on its own door. + let listing = PipelineDocument::DeskListing(huginn_mind::eureka_pipeline::PipelineDeskListing { campaign: slug("eureka-state"), listed: short(FORK), reason: "Members may read why.".into() }); + let admitted = rig.daemon.handle(HuginnMindRequest::Admit(batch(INSTANCE, vec![listing])), now()).answered(); + assert!(matches!(admitted, HuginnMindResponse::Admit(huginn_mind::PipelineAdmissionOutcome::Committed { .. })), "{admitted:?}"); let (list, read) = rig.serving(|path| (reply_of(path, "m-2", &forks("outsider", &["x"], None, None, 50)), reply_of(path, "m-3", &fork_read("outsider", &["x"], FORK)))); let (list, _) = page_of(list); let (read, _) = entry_of(read); @@ -882,8 +896,8 @@ mod door_smoke { } let seen = rig.serving(|path| reply_of(path, "m-4", &fork_read("x", &["x"], FORK))); let (seen, _) = entry_of(seen); - assert_eq!(seen.card.deciders, ["x"]); - assert_eq!(seen.record.answers[0].answerer.as_deref(), Some("y")); + assert_eq!(seen.card.deciders, [acct("x").0]); + assert_eq!(seen.record.answers[0].answerer.as_deref(), Some(acct("y").0.as_str())); } #[test] @@ -902,7 +916,7 @@ mod door_smoke { let refused = rig.serving(|path| failure_of(path, &bytes)); assert_eq!(refused.code, "payload-not-a-request", "{what}"); assert!(refused.message.contains("deciders"), "{what}: {}", refused.message); - assert!(!refused.message.contains("d64"), "{what} echoes a decider: {}", refused.message); + assert!(!refused.message.contains(&acct("d64").0), "{what} echoes a decider: {}", refused.message); } assert_eq!(rig.state(), before, "nothing stored, head unchanged"); } diff --git a/crates/huginn-daemon/tests/desk_door.rs b/crates/huginn-daemon/tests/desk_door.rs index 9da252a..de401ad 100644 --- a/crates/huginn-daemon/tests/desk_door.rs +++ b/crates/huginn-daemon/tests/desk_door.rs @@ -191,12 +191,18 @@ impl Drop for Daemon { } } +/// A test's name for someone as the Bifrost account id the door requires: a deterministic lowercase D-format UUID. +fn acct(name: &str) -> huginn_mind::eureka_pipeline::AccountId { + let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)); + huginn_mind::eureka_pipeline::AccountId(format!("{:08x}-{:04x}-4000-8000-{:012x}", hash >> 32, (hash >> 16) & 0xffff, hash & 0xffff_ffff_ffff)) +} + fn answer(who: &str, hash: &str, request_id: &str) -> DeskRequest { DeskRequest::Answer { mind: INSTANCE.into(), - subject: short(who), + subject: acct(who), authority: DeskAuthority::Operator, - deciders: vec![short(who)], + deciders: vec![acct(who)], request: ForkAnswerRequest { schema_version: RequestSchema::V0, fork_id: FORK.into(), @@ -213,8 +219,8 @@ fn answer(who: &str, hash: &str, request_id: &str) -> DeskRequest { fn forks_request(who: &str, deciders: &[&str]) -> DeskRequest { DeskRequest::Forks { mind: INSTANCE.into(), - subject: short(who), - deciders: deciders.iter().map(|d| short(d)).collect(), + subject: acct(who), + deciders: deciders.iter().map(|d| acct(d)).collect(), status: None, cursor: None, limit: huginn_mind::desk_wire::ForkLimit::new(50).unwrap(), @@ -284,14 +290,14 @@ fn the_binary_serves_both_doors_and_survives_restart() { }; 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() }) + let DeskResponse::Fork { entry, as_of } = ask(&desk, "m-r2", &DeskRequest::Fork { mind: INSTANCE.into(), subject: acct("x"), deciders: vec![acct("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() })] { + for (operation, request) in [("forks", forks_request("x", &["x"])), ("fork", DeskRequest::Fork { mind: INSTANCE.into(), subject: acct("x"), deciders: vec![acct("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"); @@ -319,16 +325,20 @@ fn the_binary_serves_both_doors_and_survives_restart() { 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, and is told no account id. + // A subject the call's deciders do not name is a non-decider: the fork is unlisted, so it reads as a missing one. + let outsider = ask(&desk, "m-r3", &DeskRequest::Fork { mind: INSTANCE.into(), subject: acct("outsider"), deciders: vec![acct("y")], fork_id: FORK.into() }); + assert!(matches!(outsider, DeskResponse::NotStored(ref refusal) if refusal.code == huginn_mind::desk_wire::ForkRefusalCode::ForkUnknown), "{outsider:?}"); + // A decider reads both answers, the first with its choice and the stale one with its code, and is told who answered. let DeskResponse::Fork { entry, .. } = - ask(&desk, "m-r3", &DeskRequest::Fork { mind: INSTANCE.into(), subject: short("outsider"), deciders: vec![short("y")], fork_id: FORK.into() }) + ask(&desk, "m-r4", &DeskRequest::Fork { mind: INSTANCE.into(), subject: acct("y"), deciders: vec![acct("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_deref(), answer.code)).collect(); - assert_eq!(seen, [("n1", None, None), ("n2", None, Some(ForkAnswerCode::StaleFork))]); + let x = acct("x").0; + assert_eq!(seen, [("n1", Some(x.as_str()), None), ("n2", Some(x.as_str()), Some(ForkAnswerCode::StaleFork))]); assert_eq!((entry.record.counted_answer_id.as_deref(), entry.record.answerer_count), (Some("n1"), 1)); - assert_eq!((entry.card.deciders.as_slice(), entry.card.decider_count), (&[][..], 1)); + assert_eq!((entry.card.deciders.as_slice(), entry.card.decider_count), (&[acct("y").0][..], 1)); assert_eq!(daemon.documents(), stored); // A stop takes the socket with it; the stored answers are the retry's memory. @@ -580,7 +590,7 @@ fn forks_page(who: &str, cursor: Option<&str>) -> DeskRequest { } 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() } + DeskRequest::Fork { mind: INSTANCE.into(), subject: acct(who), deciders: vec![acct(who)], fork_id: fork_id.into() } } /// The wire spelling of a not-stored refusal's code, and its text. diff --git a/crates/huginn-mind/src/admission.rs b/crates/huginn-mind/src/admission.rs index da0681c..38ca042 100644 --- a/crates/huginn-mind/src/admission.rs +++ b/crates/huginn-mind/src/admission.rs @@ -62,12 +62,18 @@ pub(crate) enum Door { Desk, } -/// Every staged `desk_answer`, whether the caller sent it or a derivation -/// produced it, is refused unless it came by the desk door. -fn refuse_desk_answers_outside_desk(door: Door, docs: &Docs<'_>) -> Result<(), MindRefusal> { - match docs.batch.iter().find(|staged| staged.kind == PipelineKind::DeskAnswer) { - Some(staged) if door != Door::Desk => Err(MindRefusal::DeskAnswerOutsideDesk { id: staged.key.clone() }), - _ => Ok(()), +/// The door split. Every staged `desk_answer`, whether the caller sent it or a derivation produced it, +/// is refused unless it came by the desk door; and a staged `desk_listing` is refused if it came by the +/// desk door: listing is the Mind's own act, on the Admit door, with a receipt saying who. +fn refuse_documents_at_the_wrong_door(door: Door, docs: &Docs<'_>) -> Result<(), MindRefusal> { + match docs.batch.iter().find(|staged| match staged.kind { + PipelineKind::DeskAnswer => door != Door::Desk, + PipelineKind::DeskListing => door == Door::Desk, + _ => false, + }) { + Some(staged) if staged.kind == PipelineKind::DeskListing => Err(MindRefusal::DeskRequestInvalid { field: "desk_listing".into() }), + Some(staged) => Err(MindRefusal::DeskAnswerOutsideDesk { id: staged.key.clone() }), + None => Ok(()), } } @@ -160,7 +166,7 @@ impl Mind { for envelope in envelopes { docs.push(stage(envelope, &mind)?)?; } - refuse_desk_answers_outside_desk(door, &docs)?; + refuse_documents_at_the_wrong_door(door, &docs)?; // A6 let carries_instance = docs.batch.iter().filter(|staged| staged.kind == PipelineKind::Instance).count() == 1; if self.is_empty() && !carries_instance { @@ -184,7 +190,7 @@ impl Mind { docs.push(stage(envelope, &mind)?)?; } } - refuse_desk_answers_outside_desk(door, &docs)?; + refuse_documents_at_the_wrong_door(door, &docs)?; for staged in &docs.batch[originals..] { resolve(&docs, staged, &mind, external, &mut strong)?; } @@ -627,6 +633,9 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo } // Decided in `answer_desk` before the document exists; no other door can stage one. D::DeskAnswer(_) => Ok(()), + // The question is a citation (`listed`), so a missing one is refused with every other missing referent, and + // the key derivation holds it to the listing's own campaign. + D::DeskListing(_) => Ok(()), D::Ruling(ruling) => { if ruling.operator_quote.is_some() && ruling.authority != RulingAuthority::Operator { return Err(MindRefusal::QuoteWithoutOperator { ruling: key.into() }); @@ -905,6 +914,7 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool { (K::Target, O::Superseded { by }) => all(by, K::Target), (K::Question, O::Answered { by }) => by.kind == K::Ruling, (K::Question, O::Withdrawn { .. }) => true, + (K::DeskListing, O::Withdrawn { .. }) => true, (K::Ruling, O::Superseded { by }) => all(by, K::Ruling), (K::CutSpec, O::Superseded { by }) => all(by, K::CutSpec), (K::CutSpec, O::Withdrawn { .. }) => true, diff --git a/crates/huginn-mind/src/desk.rs b/crates/huginn-mind/src/desk.rs index 34702d2..89d8e01 100644 --- a/crates/huginn-mind/src/desk.rs +++ b/crates/huginn-mind/src/desk.rs @@ -19,6 +19,7 @@ use chrono::{DateTime, Duration, SecondsFormat, SubsecRound, Utc}; use eureka_pipeline::{ + AccountId, DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, PipelineRefusal, PipelineResolution, RequestId, ResolutionOutcome, Sha256Hex, Short, Slug, Words, }; @@ -145,7 +146,7 @@ fn counts_under(answer: &PipelineDeskAnswer, current: &Sha256Hex) -> bool { /// `answer_desk` runs before it reads the fork and the door runs before its showability gate. pub(crate) fn bound_answer<'a>(docs: &'a Docs<'_>, answerer: &Short, request_id: &RequestId) -> Option<(&'a str, &'a PipelineDeskAnswer)> { docs.of_kind(PipelineKind::DeskAnswer).find_map(|(key, document)| match document { - PipelineDocument::DeskAnswer(held) if held.answerer == *answerer && held.request_id == *request_id => Some((key, held)), + PipelineDocument::DeskAnswer(held) if held.answerer.0 == answerer.0 && held.request_id == *request_id => Some((key, held)), _ => None, }) } @@ -314,7 +315,7 @@ impl Mind { claim_in_future, request_id: request.request_id.clone(), body_sha256: body_sha256.clone(), - answerer: stamp.answerer.clone(), + answerer: AccountId(stamp.answerer.0.clone()), authority: stamp.authority, outcome, }; @@ -425,7 +426,7 @@ mod tests { const LIST: [&str; 6] = ["x", "y", "z", "a", "op", "dec"]; fn stamp_as(who: &str, authority: DeskAuthority, list: &[&str]) -> DeskStamp { - DeskStamp { answerer: s(who), authority, deciders: list.iter().map(|id| s(id)).collect() } + DeskStamp { answerer: account(who), authority, deciders: list.iter().map(|id| account(id)).collect() } } fn decider(who: &str) -> DeskStamp { @@ -531,7 +532,7 @@ mod tests { let mut mind = desk(); stored(ask(&mut mind, &decider("z"), pick(FORK, "Q1", "A", "2026-11-10T12:00:00Z"))); stored(ask(&mut mind, &decider("a"), pick(FORK, "Q1", "A", "2026-11-10T12:00:00Z"))); - assert_eq!(held(&mind, &counted(&mind, FORK).unwrap()).answerer, s("z")); + assert_eq!(held(&mind, &counted(&mind, FORK).unwrap()).answerer, acct("z")); } #[test] @@ -780,7 +781,7 @@ mod tests { let record = held(&mind, &answer); assert_eq!(record.outcome, outcome); assert_eq!(outcome, DeskOutcome::Refused { code: DeskRefusal::NotADecider, retracts: None }); - assert_eq!((record.answerer, record.authority, record.answered_at), (s("guest"), DeskAuthority::Steward, s(at))); + assert_eq!((record.answerer, record.authority, record.answered_at), (acct("guest"), DeskAuthority::Steward, s(at))); } #[test] @@ -790,7 +791,7 @@ mod tests { for (who, authority) in [("op", DeskAuthority::Operator), ("dec", DeskAuthority::Steward), ("x", DeskAuthority::Member)] { let (answer, _) = stored(ask(&mut mind, &stamp_as(who, authority, &LIST), pick(FORK, "Q1", "A", at))); let record = held(&mind, &answer); - assert_eq!((record.answerer, record.authority), (s(who), authority)); + assert_eq!((record.answerer, record.authority), (acct(who), authority)); } } @@ -1039,7 +1040,7 @@ mod tests { let DeskAnswerOutcome::Stored { receipt_id, .. } = outcome else { panic!("{outcome:?}") }; let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap(); assert_eq!(receipt.provenance.faculty, faculty, "{authority:?}"); - assert_eq!(receipt.provenance.agent, s("x")); + assert_eq!(receipt.provenance.agent, account("x")); } } @@ -1206,10 +1207,10 @@ mod tests { let image = first.image().unwrap(); let found = Docs::at(image, image.head).find(K::DeskAnswer, &key("Q1", 1)).cloned(); let Some(PipelineDocument::DeskAnswer(mut held)) = found else { panic!("stored") }; - held.answerer = s(&long); + held.answerer = eureka_pipeline::AccountId(long.clone()); store.plant(PipelineDocument::DeskAnswer(held).prepare(&cache).unwrap()); let mut mind = opened(store, INSTANCE); - let stamp = stamp_as(&long, DeskAuthority::Member, &LIST); + let stamp = DeskStamp { answerer: s(&long), ..stamp_as("x", DeskAuthority::Member, &LIST) }; let before = head(&mind); assert_eq!(replayed(ask(&mut mind, &stamp, request)), (key("Q1", 1), DeskOutcome::Counted)); assert_eq!(head(&mind), before); @@ -1262,8 +1263,8 @@ mod tests { assert_eq!(stored(ask(&mut mind, &decider("x"), sent.clone())), (key("Q1", 1), DeskOutcome::Counted)); let (y_answer, y_outcome) = stored(ask(&mut mind, &decider("y"), sent.clone())); assert_eq!((y_answer.as_str(), &y_outcome), (key("Q1", 2).as_str(), &DeskOutcome::Revision { of: s(&key("Q1", 1)) })); - assert_eq!(held(&mind, &y_answer).answerer, s("y")); - assert_eq!(held(&mind, &key("Q1", 1)).answerer, s("x")); + assert_eq!(held(&mind, &y_answer).answerer, acct("y")); + assert_eq!(held(&mind, &key("Q1", 1)).answerer, acct("x")); // x's later retry still returns n1; y's returns n2. assert_eq!(replayed(ask(&mut mind, &decider("x"), sent.clone())).0, key("Q1", 1)); assert_eq!(replayed(ask(&mut mind, &decider("y"), sent)).0, key("Q1", 2)); diff --git a/crates/huginn-mind/src/desk_wire.rs b/crates/huginn-mind/src/desk_wire.rs index 45d39c1..be43cc8 100644 --- a/crates/huginn-mind/src/desk_wire.rs +++ b/crates/huginn-mind/src/desk_wire.rs @@ -20,7 +20,7 @@ pub use reads::{CardSchema, CardStatus, EntrySchema, ForkCard, ForkCursor, ForkE use chrono::{DateTime, Utc}; use eureka_pipeline::{ - DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, RequestId, Sha256Hex, Short, Slug, Words, + AccountId, DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, RequestId, Sha256Hex, Short, Slug, Words, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -232,18 +232,33 @@ pub const DESK_PAGE_BUDGET: usize = DESK_MAX_RESPONSE_BYTES - DESK_ENVELOPE_MARG /// 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, D::Error> { +/// An account id read from the wire: a lowercase D-format UUID, or the decoder's refusal, which names the +/// field and never quotes the value. +fn account_id<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + let id = AccountId(String::deserialize(deserializer)?); + id.validate_account().map_err(|_| serde::de::Error::custom("not an account id: a lowercase D-format UUID"))?; + Ok(id) +} + +/// A decider list, read whole or not at all: one to `DESK_DECIDERS_MAX` account ids, none twice. A +/// bad list is the decoder's refusal, which names the field and never quotes an id. +fn deciders<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error> { struct List; + /// One list entry, through the same grammar as `subject`. + struct Account(AccountId); + impl<'de> Deserialize<'de> for Account { + fn deserialize>(deserializer: D) -> Result { + account_id(deserializer).map(Self) + } + } impl<'de> serde::de::Visitor<'de> for List { - type Value = Vec; + type Value = Vec; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("a list of distinct deciders") } - fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { let mut list = Vec::new(); - while let Some(subject) = seq.next_element::()? { + while let Some(Account(subject)) = seq.next_element::()? { if list.len() == DESK_DECIDERS_MAX { return Err(serde::de::Error::custom("too many deciders")); } @@ -264,7 +279,7 @@ fn deciders<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result schemars::Schema { schemars::json_schema!({ "type": "array", - "items": generator.subschema_for::(), + "items": generator.subschema_for::(), "minItems": 1, "maxItems": DESK_DECIDERS_MAX, "uniqueItems": true @@ -279,8 +294,9 @@ fn deciders_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schem /// /// 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 `deciders` -/// feed the showability gate, and `subject` decides the view: a subject in `deciders` is a decider -/// and is told the ids; any other is told counts in their place. `Forks` takes the contract's `list_request` fields flat +/// feed the showability gate, and `subject` decides the view: a subject in `deciders` is a decider, +/// sees every showable fork and is told the ids; any other reads only the forks the Mind lists +/// (a `desk_listing` in force) and is told counts in the ids' place. `Forks` takes the contract's `list_request` fields flat /// beside them (`status`, `cursor`, `limit`), each key written, `null` where absent. Bifrost /// strips notes; see `DeskResponse` for the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the /// reads spec. @@ -289,19 +305,21 @@ fn deciders_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schem pub enum DeskRequest { Answer { mind: Slug, - subject: Short, + #[serde(deserialize_with = "account_id")] + subject: AccountId, authority: DeskAuthority, #[serde(deserialize_with = "deciders")] #[schemars(schema_with = "deciders_schema")] - deciders: Vec, + deciders: Vec, request: ForkAnswerRequest, }, Forks { mind: Slug, - subject: Short, + #[serde(deserialize_with = "account_id")] + subject: AccountId, #[serde(deserialize_with = "deciders")] #[schemars(schema_with = "deciders_schema")] - deciders: Vec, + deciders: Vec, #[serde(deserialize_with = "required")] #[schemars(required)] status: Option, @@ -312,10 +330,11 @@ pub enum DeskRequest { }, Fork { mind: Slug, - subject: Short, + #[serde(deserialize_with = "account_id")] + subject: AccountId, #[serde(deserialize_with = "deciders")] #[schemars(schema_with = "deciders_schema")] - deciders: Vec, + deciders: Vec, fork_id: String, }, } @@ -337,7 +356,7 @@ impl DeskRequest { } } - pub fn deciders(&self) -> &[Short] { + pub fn deciders(&self) -> &[AccountId] { match self { Self::Answer { deciders, .. } | Self::Forks { deciders, .. } | Self::Fork { deciders, .. } => deciders, } @@ -632,14 +651,14 @@ mod tests { } fn deciders(list: &[&str]) -> Vec { - list.iter().map(|who| s(who)).collect() + list.iter().map(|who| account(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, who: &str, list: &[&str], request: ForkAnswerRequest) -> (DeskResponse, bool) { let before = head(mind); - let response = mind.desk_answer(&deciders(list), s(who), DeskAuthority::Member, request, now()); + let response = mind.desk_answer(&deciders(list), account(who), DeskAuthority::Member, request, now()); (response, head(mind) != before) } @@ -779,7 +798,7 @@ mod tests { request_id: RequestId(text("request_id").unwrap()), body_sha256: Sha256Hex("0".repeat(64)), // A withheld vector names no answerer: store a placeholder, the vector is the non-decider's view of it. - answerer: Short(text("answerer").unwrap_or_else(|| "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f".into())), + answerer: AccountId(text("answerer").unwrap_or_else(|| "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f".into())), authority, outcome, }; @@ -1355,9 +1374,9 @@ mod tests { let (other, committed) = ask(&mut mind, "y", request.clone()); let (other, _, _) = answered(other); assert!(committed); - assert_eq!((other.answer_id.as_str(), other.answerer.as_deref().unwrap(), other.outcome.as_str(), other.request_id.as_str()), ("n2", "y", "Revision", request.request_id.as_str())); + assert_eq!((other.answer_id.as_str(), other.answerer.as_deref().unwrap(), other.outcome.as_str(), other.request_id.as_str()), ("n2", account("y").0.as_str(), "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_deref().unwrap()), ("n1", "x")); + assert_eq!((original.answer_id.as_str(), original.answerer.as_deref().unwrap()), ("n1", account("x").0.as_str())); // V17 and V18: an id that is no lowercase D-format UUID is body_invalid naming request_id, and binds nothing. let before = head(&mind); for bad in ["retry-1", "0000000A-0000-4000-8000-00000000000B", "{00000000-0000-4000-8000-000000000001}", "00000000-0000-4000-8000-0000000000010", "", " 00000000-0000-4000-8000-000000000001", "00000000-0000-4000-8000-000000000001 ", "00000000-0000-4000-8000-000000000001\n", "00000000-0000-4000-8000-000000000001x"] { @@ -1568,27 +1587,27 @@ mod tests { 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()); + let off = mind.desk_answer(&deciders(&["x"]), account("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()); + let on = mind.desk_answer(&deciders(&["m"]), account("m"), DeskAuthority::Member, wire(FORK, &hash), now()); let (answer, _, _) = answered(on); - assert_eq!((answer.outcome.as_str(), answer.authority, answer.answerer.as_deref().unwrap()), ("Counted", DeskAuthority::Member, "m")); + assert_eq!((answer.outcome.as_str(), answer.authority, answer.answerer.as_deref().unwrap()), ("Counted", DeskAuthority::Member, account("m").0.as_str())); } // ---- 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 { - (0..n).map(|at| s(&format!("d{at}"))).collect() + fn list_of(n: usize) -> Vec { + (0..n).map(|at| acct(&format!("d{at}"))).collect() } /// Each request kind of the desk, carrying `list`. - fn desk_requests(mind: &str, list: &[Short], hash: &str) -> [DeskRequest; 3] { + fn desk_requests(mind: &str, list: &[AccountId], 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() }, + DeskRequest::Answer { mind: slug(mind), subject: acct("d0"), authority: DeskAuthority::Member, deciders: list.to_vec(), request: wire(FORK, hash) }, + DeskRequest::Forks { mind: slug(mind), subject: acct("d0"), deciders: list.to_vec(), status: None, cursor: None, limit: ForkLimit::new(10).unwrap() }, + DeskRequest::Fork { mind: slug(mind), subject: acct("d0"), deciders: list.to_vec(), fork_id: FORK.into() }, ] } @@ -1611,7 +1630,7 @@ mod tests { // 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()); + let response = mind.desk_answer(&deciders.iter().map(|id| s(&id.0)).collect::>(), s(&subject.0), authority, request, now()); assert!(head(&mind) > before); assert_eq!(answered(response).0.outcome, "Counted"); @@ -1631,7 +1650,7 @@ mod tests { let hash = hash_of(&mind, FORK); let before = (head(&mind), stored_count(&mind, FORK)); let mut twice = list_of(3); - twice.push(s("d1")); + twice.push(acct("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(); @@ -1667,6 +1686,6 @@ mod tests { // 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"); + assert_eq!(answered(mind.desk_answer(&deciders.iter().map(|id| s(&id.0)).collect::>(), s(&subject.0), authority, request, now())).0.outcome, "Counted"); } } diff --git a/crates/huginn-mind/src/desk_wire/reads.rs b/crates/huginn-mind/src/desk_wire/reads.rs index 224ca9d..064a6b4 100644 --- a/crates/huginn-mind/src/desk_wire/reads.rs +++ b/crates/huginn-mind/src/desk_wire/reads.rs @@ -352,6 +352,18 @@ fn card_of(docs: &Docs<'_>, key: &str, order: u64, question: &PipelineQuestion, } } +/// Whether a reader may see this fork at all: a decider sees every showable fork (they must, to answer +/// it); anyone else only while the Mind's `desk_listing` for it is in force (derived from the +/// resolutions on every read; no status is stored). A fork nobody may see reads as a missing one. +fn visible(docs: &Docs<'_>, key: &str, decider: bool) -> bool { + decider || { + let (root, label) = root_and_local(key); + let listing = format!("{root}:desk_listing:{label}"); + // `in_force` is true of a document nobody wrote: a listing counts only if the Mind holds it. + docs.in_image(PipelineKind::DeskListing, &listing).is_some() && docs.in_force(PipelineKind::DeskListing, &listing) + } +} + /// Every question the Mind holds at the snapshot, showable or not, with its rank: the 0-based position /// in ascending (admission ordinal, key) order. A rank is unique, and admissions only append, so a held /// question's rank never moves; a question the deciders do not show leaves a gap and shifts no other. @@ -427,15 +439,17 @@ impl Mind { /// The fork itself is still its whole entry on `desk_fork`; if that entry is past the reply cap the door /// answers the read with `fork_too_large` naming the fork (no order, no cursor), not `ResponseTooLarge`. Nothing is dropped silently. /// - /// The call's `deciders` feed the showability gate; the call's `subject` decides which view of each entry - /// is built (a decider's, with ids, or a non-decider's, with counts). It gates no fork. Notes stay whole - /// (Bifrost strips them). + /// The call's `deciders` feed the showability gate; the call's `subject` decides which forks are visible and + /// which view of each entry is built. A decider sees every showable fork, with ids; any other reader sees a + /// showable fork only while the Mind's `desk_listing` for it is in force, with counts. The counts, the + /// pages and the cursor are over the visible set; `order` and `stamp` are not (a non-decider sees gaps). + /// Notes stay whole (Bifrost strips them). pub fn desk_forks(&self, subject: &Short, deciders: &[Short], status: Option, cursor: Option, limit: ForkLimit) -> DeskResponse { self.forks_page(subject, deciders, status, cursor, limit).unwrap_or_else(door_refusal) } /// 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. The view is the reader's, as `desk_forks`. + /// question, is not showable or is not visible to the reader is `fork_unknown`, the answer path's refusal, with the same bytes as a missing fork. The view is the reader's, as `desk_forks`. pub fn desk_fork(&self, subject: &Short, deciders: &[Short], fork_id: &str) -> DeskResponse { self.fork_entry(subject, deciders, fork_id).unwrap_or_else(door_refusal) } @@ -444,7 +458,7 @@ impl Mind { let image = self.image()?; let docs = Docs::at(image, image.head); let decider = deciders.contains(subject); - let mut shown: Vec<_> = ranked(&docs)?.into_iter().filter(|(_, _, question)| showable(question, deciders)).map(|(order, key, question)| (order, key, question, docs.in_force(PipelineKind::Question, key))).collect(); + let mut shown: Vec<_> = ranked(&docs)?.into_iter().filter(|(_, key, question)| showable(question, deciders) && visible(&docs, key, decider)).map(|(order, key, question)| (order, key, question, docs.in_force(PipelineKind::Question, key))).collect(); shown.reverse(); let open_count = shown.iter().filter(|fork| fork.3).count() as u64; let recorded_count = shown.len() as u64 - open_count; @@ -499,7 +513,7 @@ impl Mind { let Some((order, key, question)) = ranked(&docs)?.into_iter().find(|(_, key, _)| *key == fork_id) else { return Ok(DeskResponse::NotStored(fork_unknown())); }; - if !showable(question, deciders) { + if !showable(question, deciders) || !visible(&docs, key, decider) { return Ok(DeskResponse::NotStored(fork_unknown())); } Ok(DeskResponse::Fork { entry: entry_of(&docs, key, order, question, deciders, decider)?, as_of: image.head }) @@ -536,7 +550,7 @@ mod tests { } fn list(who: &[&str]) -> Vec { - who.iter().map(|id| s(id)).collect() + who.iter().map(|id| account(id)).collect() } fn head(mind: &Mind) -> u64 { @@ -568,7 +582,7 @@ mod tests { answered_at: "2026-09-16T11:00:00Z".into(), request_id: request_id(), }; - mind.desk_answer(&list(deciders), s(who), DeskAuthority::Member, request, now()) + mind.desk_answer(&list(deciders), account(who), DeskAuthority::Member, request, now()) } fn close(mind: &mut Mind, fork: &str, label: &str) { @@ -605,7 +619,7 @@ mod tests { } fn all(mind: &Mind, n: u8) -> ForkList { - page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(n))).0 + page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(n))).0 } // ---- the reads, one snapshot ---- @@ -616,7 +630,7 @@ mod tests { let stale = answer(&mut mind, "x", &LIST, &fork(1), &"0".repeat(64), None); let DeskResponse::Answered { answer: stale, .. } = stale else { panic!("a stale answer is stored") }; assert_eq!(stale.code, Some(ForkAnswerCode::StaleFork)); - let (shown, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1))); + let (shown, _) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1))); assert_eq!(shown.card.options_hash, hash_of(&mind, &fork(1))); assert_ne!(shown.card.options_hash, "0".repeat(64)); assert_eq!((shown.record.answers.len(), shown.record.counted_answer_id.clone()), (1, None)); @@ -627,7 +641,7 @@ mod tests { }; assert_eq!((counted.outcome.as_str(), counted.answer_id.as_str()), ("Counted", "n2")); assert_eq!(counted_answer_id.as_deref(), Some("n2")); - let (after, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1))); + let (after, _) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1))); assert_eq!(after.record.counted_answer_id.as_deref(), Some("n2")); let names: Vec<_> = after.record.answers.iter().map(|answer| answer.answer_id.as_str()).collect(); assert_eq!(names, ["n1", "n2"], "answers in admission order, named n"); @@ -636,23 +650,23 @@ mod tests { #[test] fn list_then_read_at_one_stamp() { let mut mind = desk(3); - let (first, as_of) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(2))); + let (first, as_of) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(2))); assert_eq!(as_of, head(&mind)); assert_eq!(first.stamp, format!("s{as_of}")); assert_eq!(first.mind, INSTANCE); assert_eq!(ids(&first), [fork(3), fork(2)]); let next = first.next_cursor.expect("one more fork waits"); assert_eq!(next.order(), first.forks[1].card.order); - let (second, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, Some(next), limit(2))); + let (second, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, Some(next), limit(2))); assert_eq!((ids(&second), second.next_cursor), (vec![fork(1)], None)); // The read at that stamp is the entry the list carried. - let (read, read_at) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(2))); + let (read, read_at) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(2))); assert_eq!(read_at, as_of); assert_eq!(read, first.forks[1]); // Writing moves the stamp, and only then. let hash = hash_of(&mind, &fork(2)); answer(&mut mind, "x", &LIST, &fork(2), &hash, None); - let (moved, moved_at) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(2))); + let (moved, moved_at) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(2))); assert_eq!((moved.stamp.clone(), moved_at), (format!("s{}", head(&mind)), head(&mind))); assert_ne!(moved.stamp, first.stamp); assert_eq!(moved.forks[1].record.answers.len(), 1); @@ -671,17 +685,17 @@ mod tests { assert_eq!(ForkCursor::new(0), None, "no cursor names rank 0: nothing is below it"); for (at, expected) in [(0, 3), (1, 2), (2, 1)] { let below = ForkCursor::new(orders[at]).unwrap(); - let (rest, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, Some(below), limit(50))); + let (rest, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, Some(below), limit(50))); assert_eq!(rest.forks.len(), expected, "below order {}", orders[at]); assert!(rest.forks.iter().all(|entry| entry.card.order < orders[at])); } // A page that stops short names the last order it returned. - let (short, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(1))); + let (short, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(1))); assert_eq!((ids(&short), short.next_cursor.map(ForkCursor::order)), (vec![fork(4)], Some(orders[0]))); // A page that ends exactly on the last fork has no cursor. - let (exact, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(4))); + let (exact, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(4))); assert_eq!((exact.forks.len(), exact.next_cursor), (4, None)); - let (over, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50))); + let (over, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50))); assert_eq!(over.forks.len(), 4); } @@ -694,7 +708,7 @@ mod tests { for status in [None, Some(ListStatus::Open), Some(ListStatus::Recorded)] { for cursor in [None, ForkCursor::new(1), ForkCursor::new(3), ForkCursor::new(1000)] { for size in [0, 1, 50] { - let (shown, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), status, cursor, limit(size))); + let (shown, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), status, cursor, limit(size))); assert_eq!((shown.open_count, shown.recorded_count), expected, "{status:?} {cursor:?} {size}"); assert!(shown.forks.len() <= size as usize); for entry in &shown.forks { @@ -708,31 +722,31 @@ mod tests { } } } - let (open, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), Some(ListStatus::Open), None, limit(50))); + let (open, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), Some(ListStatus::Open), None, limit(50))); assert_eq!(ids(&open), [fork(5), fork(3), fork(2)]); - let (recorded, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), Some(ListStatus::Recorded), None, limit(50))); + let (recorded, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), Some(ListStatus::Recorded), None, limit(50))); assert_eq!(ids(&recorded), [fork(4), fork(1)]); // A cursor and a status together: the cards below the cursor that have the status. let below_four = ForkCursor::new(recorded.forks[0].card.order); - let (rest, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), Some(ListStatus::Open), below_four, limit(50))); + let (rest, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), Some(ListStatus::Open), below_four, limit(50))); assert_eq!(ids(&rest), [fork(3), fork(2)]); } #[test] fn limit_zero_is_the_counts_and_the_stamp_and_no_entries() { let mind = desk(3); - let (counts, as_of) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(0))); + let (counts, as_of) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(0))); assert_eq!((counts.forks.len(), counts.next_cursor), (0, None)); assert_eq!((counts.open_count, counts.recorded_count), (3, 0)); assert_eq!((counts.stamp, as_of), (format!("s{}", head(&mind)), head(&mind))); - let (one, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(1))); + let (one, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(1))); assert_eq!(one.forks.len(), 1, "limit 1 is one entry, not none"); } #[test] fn a_desk_with_no_forks_is_an_empty_page() { let mind = seeded(); - let (empty, as_of) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(10))); + let (empty, as_of) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(10))); assert_eq!((empty.open_count, empty.recorded_count, empty.forks.len(), empty.next_cursor), (0, 0, 0, None)); assert_eq!(empty.stamp, format!("s{as_of}")); } @@ -757,11 +771,11 @@ mod tests { let shown = all(&mind, 50); assert_eq!(ids(&shown), [fork(2), fork(1)]); assert_eq!((shown.open_count, shown.recorded_count), (2, 0), "the count is over the showable set"); - unknown(mind.desk_fork(&s(LIST[0]), &list(&LIST), &unshowable)); + unknown(mind.desk_fork(&account(LIST[0]), &list(&LIST), &unshowable)); // No decider, nothing is shown at all: the gate needs one. - let (none, _) = page(mind.desk_forks(&s(LIST[0]), &[], None, None, limit(50))); + let (none, _) = page(mind.desk_forks(&account(LIST[0]), &[], None, None, limit(50))); assert_eq!((none.forks.len(), none.open_count, none.recorded_count), (0, 0, 0)); - unknown(mind.desk_fork(&s(LIST[0]), &[], &fork(1))); + unknown(mind.desk_fork(&account(LIST[0]), &[], &fork(1))); } #[test] @@ -770,12 +784,12 @@ mod tests { close(&mut mind, &fork(1), "R1"); let ruling_key = format!("{CAMPAIGN_ROOT}:ruling:R1"); for id in [fork(9).as_str(), "", "Q1", "no-colons", "eureka-state:question:", ruling_key.as_str(), &"x".repeat(5000), "other-campaign:question:Q1"] { - unknown(mind.desk_fork(&s(LIST[0]), &list(&LIST), id)); + unknown(mind.desk_fork(&account(LIST[0]), &list(&LIST), id)); } // And nothing was written by looking. let before = head(&mind); - let _ = mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1)); - let _ = mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50)); + let _ = mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1)); + let _ = mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50)); assert_eq!(head(&mind), before); } @@ -790,6 +804,9 @@ mod tests { /// and Ben answers a second time (a retraction): four stored answers, three answerers. fn answered_desk() -> Mind { let mut mind = desk(2); + // Listed: a reader who is not a decider sees these forks (and their counts) at all. + list_it(&mut mind, 1); + list_it(&mut mind, 2); let hash = hash_of(&mind, &fork(1)); let deciders = [ANNA, BEN]; answer(&mut mind, ANNA, &deciders, &fork(1), &hash, Some("Anna's note")); @@ -812,8 +829,8 @@ mod tests { } fn reader(mind: &Mind, subject: &str, deciders: &[&str]) -> (ForkList, ForkEntry, Vec) { - let (page, _) = page(mind.desk_forks(&s(subject), &list(deciders), None, None, limit(50))); - let response = mind.desk_fork(&s(subject), &list(deciders), &fork(1)); + let (page, _) = page(mind.desk_forks(&account(subject), &list(deciders), None, None, limit(50))); + let response = mind.desk_fork(&account(subject), &list(deciders), &fork(1)); let bytes = encode_desk_response("m", "fork", &response, "r").unwrap(); let bytes = cultnet_rs::encode_cultnet_message_to_vec(&bytes, cultnet_rs::CultNetWireContract::CultNetSchemaV0).unwrap(); let (entry, _) = entry(response); @@ -890,7 +907,7 @@ mod tests { // ---- raised_at: the question's first admission ---- fn raised_at(mind: &Mind, fork_id: &str) -> Option { - entry(mind.desk_fork(&s(ANNA), &list(&[ANNA]), fork_id)).0.card.raised_at + entry(mind.desk_fork(&account(ANNA), &list(&[ANNA]), fork_id)).0.card.raised_at } #[test] @@ -964,18 +981,189 @@ mod tests { #[test] fn raised_at_is_outside_options_hash() { let mind = desk(1); - let (read, _) = entry(mind.desk_fork(&s(ANNA), &list(&[ANNA]), &fork(1))); + let (read, _) = entry(mind.desk_fork(&account(ANNA), &list(&[ANNA]), &fork(1))); let moved = ForkCard { raised_at: Some("2020-01-01T00:00:00Z".into()), ..read.card.clone() }; assert_ne!(moved.raised_at, read.card.raised_at); assert_eq!(moved.options_hash, read.card.options_hash); assert_eq!(read.card.options_hash, hash_of(&mind, &fork(1))); } + // ---- the listing gate: a non-decider reads a fork only while the Mind lists it ---- + + fn listing_doc(n: u32) -> D { + D::DeskListing(eureka_pipeline::PipelineDeskListing { campaign: slug(CAMPAIGN_ROOT), listed: s(&fork(n)), reason: "Members may read why.".into() }) + } + + fn listing_ref(n: u32) -> PipelineRef { + PipelineRef { kind: PipelineKind::DeskListing, id: s(&format!("{CAMPAIGN_ROOT}:desk_listing:Q{n}")) } + } + + fn list_it(mind: &mut Mind, n: u32) { + committed(admit(mind, vec![listing_doc(n)])); + } + + /// Unlist: a `Withdrawn` resolution on the listing, `sequence` its per-subject sequence. + fn unlist(mind: &mut Mind, n: u32, sequence: u32) -> String { + let withdrawal = crate::fixtures::resolution_n(listing_ref(n), sequence, eureka_pipeline::ResolutionOutcome::Withdrawn { reason: "unlisted".into() }); + let key = eureka_pipeline::pipeline_key(&withdrawal).unwrap(); + committed(admit(mind, vec![withdrawal])); + key + } + + /// Relist: withdraw the withdrawal. + fn relist(mind: &mut Mind, withdrawal: &str) { + let subject = PipelineRef { kind: PipelineKind::Resolution, id: s(withdrawal) }; + committed(admit(mind, vec![crate::fixtures::resolution(subject, eureka_pipeline::ResolutionOutcome::Withdrawn { reason: "listed again".into() })])); + } + + fn seen_by(mind: &Mind, subject: &str) -> Vec { + ids(&page(mind.desk_forks(&account(subject), &list(&[ANNA, BEN]), None, None, limit(50))).0) + } + + fn read_by(mind: &Mind, subject: &str, n: u32) -> DeskResponse { + mind.desk_fork(&account(subject), &list(&[ANNA, BEN]), &fork(n)) + } + + /// Negative first: with nothing listed, a non-decider is shown nothing, and a read of a real fork + /// is the same bytes as a read of one that does not exist. + #[test] + fn an_unlisted_fork_is_invisible_to_a_non_decider() { + let mind = desk(3); + let (shown, _) = page(mind.desk_forks(&account(DAN), &list(&[ANNA, BEN]), None, None, limit(50))); + assert_eq!((shown.forks.len(), shown.open_count, shown.recorded_count, shown.next_cursor), (0, 0, 0, None)); + let (counts, _) = page(mind.desk_forks(&account(DAN), &list(&[ANNA, BEN]), None, None, limit(0))); + assert_eq!((counts.open_count, counts.recorded_count), (0, 0)); + let real = read_by(&mind, DAN, 1); + unknown(real.clone()); + assert_eq!(real, read_by(&mind, DAN, 99), "a hidden fork reads as a missing one"); + let bytes = |response: &DeskResponse| rmp_serde::to_vec_named(response).unwrap(); + assert_eq!(bytes(&real), bytes(&read_by(&mind, DAN, 99))); + } + + #[test] + fn a_decider_sees_unlisted_forks() { + let mut mind = desk(3); + list_it(&mut mind, 2); + assert_eq!(seen_by(&mind, ANNA), [fork(3), fork(2), fork(1)]); + assert_eq!(seen_by(&mind, DAN), [fork(2)]); + // Listing is not read for a decider: unlisting changes nothing for them. + unlist(&mut mind, 2, 1); + assert_eq!(seen_by(&mind, ANNA), [fork(3), fork(2), fork(1)]); + assert!(seen_by(&mind, DAN).is_empty()); + } + + #[test] + fn listing_flips_and_is_audited() { + let mut mind = desk(2); + let mut states = vec![seen_by(&mind, DAN)]; + let before = head(&mind); + list_it(&mut mind, 1); + states.push(seen_by(&mind, DAN)); + let withdrawal = unlist(&mut mind, 1, 1); + states.push(seen_by(&mind, DAN)); + relist(&mut mind, &withdrawal); + states.push(seen_by(&mind, DAN)); + unlist(&mut mind, 1, 2); + states.push(seen_by(&mind, DAN)); + let q1 = vec![fork(1)]; + assert_eq!(states, [vec![], q1.clone(), vec![], q1, vec![]]); + assert_eq!(head(&mind), before + 4, "every flip is a document with its own receipt"); + assert!(matches!(read_by(&mind, DAN, 1), DeskResponse::NotStored(_))); + } + + #[test] + fn counts_and_cursor_cover_the_visible_set() { + let mut mind = desk(5); + for n in [2, 4] { + list_it(&mut mind, n); + } + close(&mut mind, &fork(4), "R4"); + let list_for = |subject: &str, cursor, size| page(mind.desk_forks(&account(subject), &list(&[ANNA, BEN]), None, cursor, limit(size))).0; + let counts = list_for(DAN, None, 0); + assert_eq!((counts.open_count, counts.recorded_count), (1, 1)); + // Pages are gapless over the visible forks and the cursor never names a hidden one. + let first = list_for(DAN, None, 1); + assert_eq!(ids(&first), [fork(4)]); + let cursor = first.next_cursor.expect("one more visible fork"); + let second = list_for(DAN, Some(cursor), 1); + assert_eq!((ids(&second), second.next_cursor), (vec![fork(2)], None)); + // Order keeps its rank among ALL the Mind's questions: the gap is visible, the text is not. + assert_eq!(orders(&first).into_iter().chain(orders(&second)).collect::>(), [3, 1]); + } + + #[test] + fn a_listing_needs_a_question_of_its_campaign() { + let mut mind = desk(1); + let missing = refusal(admit(&mut mind, vec![listing_doc(9)])); + assert!(matches!(missing, MindRefusal::MissingReference { kind: PipelineKind::Question, .. }), "{missing:?}"); + let D::DeskListing(mut other) = listing_doc(1) else { unreachable!() }; + other.campaign = slug("other-campaign"); + let before = head(&mind); + assert!(matches!(admit(&mut mind, vec![D::DeskListing(other)]), PipelineAdmissionOutcome::Refused(_))); + assert_eq!(head(&mind), before); + } + + #[test] + fn the_desk_door_cannot_admit_a_listing() { + let mut mind = desk(1); + let envelope = crate::fixtures::prepare(&listing_doc(1)); + let instance = mind.instance().clone(); + let outcome = mind.admit_steps(crate::admission::Door::Desk, &instance, provenance(Faculty::Operator), vec![envelope], now()); + assert_eq!(outcome.unwrap_err(), MindRefusal::DeskRequestInvalid { field: "desk_listing".into() }); + assert!(seen_by(&mind, DAN).is_empty()); + } + + #[test] + fn listed_role_is_carried_by_desk_listing_only() { + use crate::docs::CitationRole as R; + assert_eq!((R::Listed.carrier(), R::Listed.target_kinds(), R::Listed.name()), (PipelineKind::DeskListing, vec![PipelineKind::Question], "listed")); + assert_eq!(R::Fork.carrier(), PipelineKind::DeskAnswer); + assert_eq!(R::ALL.len(), 27); + } + + // ---- the account grammar at decode ---- + + #[test] + fn a_decider_or_subject_outside_the_account_grammar_is_refused_at_decode() { + let good = encode_desk_request("m", &DeskRequest::Fork { mind: slug(INSTANCE), subject: acct("a"), deciders: vec![acct("a")], fork_id: fork(1) }, None).unwrap(); + decode_desk_request(&good).unwrap(); + let bad_ids = ["@x:server", "meta@gamecult.org", "someone", "7", &acct("a").0.to_uppercase(), &format!("{{{}}}", acct("a").0), ""]; + for bad in bad_ids { + for (field, edit) in [("subject", 0), ("deciders", 1)] { + let mut value = serde_json::to_value(DeskRequest::Fork { mind: slug(INSTANCE), subject: acct("a"), deciders: vec![acct("a")], fork_id: fork(1) }).unwrap(); + if edit == 0 { + value["Fork"]["subject"] = bad.into(); + } else { + value["Fork"]["deciders"] = json!([bad]); + } + let sent = encode_desk_request_value(&value); + let failure = decode_desk_request(&sent).unwrap_err(); + assert_eq!(failure.code, "payload-not-a-request", "{field}: {bad:?}"); + assert!(failure.message.contains(field), "{}", failure.message); + assert!(bad.is_empty() || !failure.message.contains(bad), "the refusal quotes {bad:?}: {}", failure.message); + } + } + } + + fn encode_desk_request_value(value: &Value) -> cultnet_rs::CultNetMessage { + use base64::Engine; + cultnet_rs::CultNetMessage::OperationRequest { + message_id: "m".into(), + service_id: DESK_SERVICE_ID.into(), + operation: "fork".into(), + payload_schema: DESK_REQUEST_SCHEMA.into(), + payload_encoding: "messagepack-base64".into(), + payload: base64::engine::general_purpose::STANDARD.encode(rmp_serde::to_vec_named(value).unwrap()), + source_runtime_id: None, + target_runtime_id: None, + } + } + // ---- the card refuses a count that does not match ---- fn card_with(deciders: usize, decider_count: u64) -> Vec { let mind = desk(1); - let (read, _) = entry(mind.desk_fork(&s(ANNA), &list(&[ANNA]), &fork(1))); + let (read, _) = entry(mind.desk_fork(&account(ANNA), &list(&[ANNA]), &fork(1))); let ids: Vec = (0..deciders).map(|n| format!("00000000-0000-4000-8000-{n:012x}")).collect(); rmp_serde::to_vec_named(&ForkCard { deciders: ids, decider_count, ..read.card }).unwrap() } @@ -1015,7 +1203,7 @@ mod tests { held.raised_in = Some(PipelineRef { kind: PipelineKind::Ruling, id: s("eureka-state:ruling:R0") }); committed(admit(&mut mind, vec![D::Ruling(ruling("R0"))])); committed(admit(&mut mind, vec![shown])); - let (read, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1))); + let (read, _) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1))); let card = &read.card; assert_eq!((card.fork_id.as_str(), card.title.as_str(), card.context.as_str()), (fork(1).as_str(), "Share the mark?", "Should a slot mark be shared?")); let options: Vec<_> = card.options.iter().map(|option| (option.id.as_str(), option.text.as_str())).collect(); @@ -1024,7 +1212,7 @@ mod tests { assert_eq!(card.depends, ["eureka-state:question:other", "a prose line"]); assert_eq!(card.raised_in, Some(ForkRef { kind: "Ruling".into(), id: "eureka-state:ruling:R0".into() })); assert_eq!((card.leans.clone(), card.flag.clone(), card.status, card.ruling.clone(), card.ruled_from.clone()), (None, None, CardStatus::Open, None, vec![])); - assert_eq!(card.deciders, LIST); + assert_eq!(card.deciders, LIST.iter().map(|id| account(id).0).collect::>()); assert_eq!(card.options_hash, hash_of(&mind, &fork(1))); assert_eq!(card.order, 0, "the only question is rank 0"); assert_eq!(read.record.fork_id, fork(1)); @@ -1035,9 +1223,9 @@ mod tests { fn a_ruling_closes_the_card_and_names_itself() { let mut mind = desk(2); close(&mut mind, &fork(1), "R1"); - let (closed, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1))); + let (closed, _) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1))); assert_eq!((closed.card.status, closed.card.ruling.as_deref()), (CardStatus::Closed, Some("eureka-state:ruling:R1"))); - let (open, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(2))); + let (open, _) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(2))); assert_eq!((open.card.status, open.card.ruling), (CardStatus::Open, None)); } @@ -1047,7 +1235,7 @@ mod tests { let hash = hash_of(&mind, &fork(1)); answer(&mut mind, "x", &LIST, &fork(1), &hash, None); answer(&mut mind, "y", &LIST, &fork(1), &hash, None); - let (read, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1))); + let (read, _) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1))); assert_eq!(read.record.counted_answer_id.as_deref(), Some("n1")); let outcomes: Vec<_> = read.record.answers.iter().map(|answer| (answer.outcome.as_str(), answer.of.as_deref())).collect(); assert_eq!(outcomes, [("Counted", None), ("Revision", Some("n1"))]); @@ -1062,9 +1250,9 @@ mod tests { answered_at: "2026-09-16T11:00:00Z".into(), request_id: request_id(), }; - let stored = mind.desk_answer(&list(&LIST), s("x"), DeskAuthority::Member, retract, now()); + let stored = mind.desk_answer(&list(&LIST), account("x"), DeskAuthority::Member, retract, now()); assert!(matches!(stored, DeskResponse::Answered { .. })); - let (after, _) = entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(1))); + let (after, _) = entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(1))); assert_eq!(after.record.counted_answer_id.as_deref(), Some("n2")); } @@ -1236,7 +1424,7 @@ mod tests { let file = transport(); let flat = |message: &Value| { json!({ "Forks": { - "mind": message["mind"], "subject": "s", "deciders": ["s"], + "mind": message["mind"], "subject": "00000000-0000-4000-8000-0000000000e5", "deciders": ["00000000-0000-4000-8000-0000000000e5"], "status": message["status"], "cursor": message["cursor"], "limit": message["limit"] }}) }; @@ -1261,7 +1449,7 @@ mod tests { } fn forks_json(status: Value, cursor: Value, limit: Value) -> Value { - json!({ "Forks": { "mind": INSTANCE, "subject": "s", "deciders": ["s"], "status": status, "cursor": cursor, "limit": limit } }) + json!({ "Forks": { "mind": INSTANCE, "subject": "00000000-0000-4000-8000-0000000000e5", "deciders": ["00000000-0000-4000-8000-0000000000e5"], "status": status, "cursor": cursor, "limit": limit } }) } #[test] @@ -1308,7 +1496,7 @@ mod tests { #[test] fn a_read_that_names_an_authority_is_not_a_request() { let mut forks = forks_json(Value::Null, Value::Null, json!(5)); - let mut fork = json!({ "Fork": { "mind": INSTANCE, "subject": "s", "deciders": ["s"], "fork_id": fork(1) } }); + let mut fork = json!({ "Fork": { "mind": INSTANCE, "subject": "00000000-0000-4000-8000-0000000000e5", "deciders": ["00000000-0000-4000-8000-0000000000e5"], "fork_id": fork(1) } }); assert!(serde_json::from_value::(forks.clone()).is_ok() && serde_json::from_value::(fork.clone()).is_ok()); for authority in [json!("Operator"), json!("Member"), Value::Null] { forks["Forks"]["authority"] = authority.clone(); @@ -1317,7 +1505,7 @@ mod tests { assert!(serde_json::from_value::(fork.clone()).is_err(), "Fork with {authority}"); } // And through the envelope the door reads. - let typed = DeskRequest::Forks { mind: slug(INSTANCE), subject: s("s"), deciders: list(&["s"]), status: None, cursor: None, limit: limit(5) }; + let typed = DeskRequest::Forks { mind: slug(INSTANCE), subject: acct("s"), deciders: vec![acct("s")], status: None, cursor: None, limit: limit(5) }; let message = encode_desk_request("m-1", &typed, None).unwrap(); assert_eq!(decode_desk_request(&message).unwrap().1, typed); } @@ -1355,7 +1543,7 @@ mod tests { fn pages(mind: &Mind, size: u8) -> Vec<(DeskResponse, ForkList)> { let (mut cursor, mut out) = (None, Vec::new()); loop { - let response = mind.desk_forks(&s(LIST[0]), &list(&LIST), None, cursor, limit(size)); + let response = mind.desk_forks(&account(LIST[0]), &list(&LIST), None, cursor, limit(size)); let (page, _) = page(response.clone()); cursor = page.next_cursor; out.push((response, page)); @@ -1406,7 +1594,7 @@ mod tests { } mind }; - let first_page = |mind: &Mind| page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50))).0; + let first_page = |mind: &Mind| page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50))).0; // The most forks that fit one page with the oldest one's text short. let fits = (2..=50).take_while(|forks| first_page(&build(*forks, 300)).forks.len() == *forks as usize).last().expect("two fit"); assert!(fits < 50, "the desk outgrows a page"); @@ -1417,18 +1605,18 @@ mod tests { if first_page(&build(fits, mid)).forks.len() == fits as usize { low = mid } else { high = mid } } let (in_page, over) = (build(fits, low), build(fits, low + 1)); - let held = page(in_page.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50))); + let held = page(in_page.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50))); assert_eq!((held.0.forks.len(), held.0.next_cursor), (fits as usize, None)); let size = bare(&DeskResponse::Forks { list: held.0, as_of: held.1 }); // The wire grows in steps of up to three bytes per text byte, so "exact" is within one step. assert!(size <= DESK_PAGE_BUDGET && DESK_PAGE_BUDGET - size < 4, "{size} against {DESK_PAGE_BUDGET}"); - let (moved, _) = page(over.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50))); + let (moved, _) = page(over.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50))); assert_eq!(moved.forks.len(), fits as usize - 1, "one byte more and the oldest fork goes to the next page"); // The page it would have been, composed from the single reads, is past the budget. - let would_be = ForkList { forks: (1..=fits).rev().map(|n| entry(over.desk_fork(&s(LIST[0]), &list(&LIST), &fork(n))).0).collect(), next_cursor: None, ..moved.clone() }; + let would_be = ForkList { forks: (1..=fits).rev().map(|n| entry(over.desk_fork(&account(LIST[0]), &list(&LIST), &fork(n))).0).collect(), next_cursor: None, ..moved.clone() }; assert!(bare(&DeskResponse::Forks { list: would_be, as_of: head(&over) }) > DESK_PAGE_BUDGET); assert_eq!(moved.next_cursor.map(ForkCursor::order), moved.forks.last().map(|entry| entry.card.order)); - let (rest, _) = page(over.desk_forks(&s(LIST[0]), &list(&LIST), None, moved.next_cursor, limit(50))); + let (rest, _) = page(over.desk_forks(&account(LIST[0]), &list(&LIST), None, moved.next_cursor, limit(50))); assert_eq!(ids(&rest), [fork(1)]); } @@ -1437,7 +1625,7 @@ mod tests { let hash = hash_of(mind, &fork(n)); let note = "a long reason, ".repeat(60); let mut added = 0; - while bare(&mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(n))) <= bytes { + while bare(&mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(n))) <= bytes { let DeskResponse::Answered { .. } = answer(mind, "x", &LIST, &fork(n), &hash, Some(¬e)) else { panic!("the note was not stored") }; added += 1; assert!(added < 200, "the record does not grow"); @@ -1457,18 +1645,18 @@ mod tests { let mut mind = desk(3); grown_past(&mut mind, 2, DESK_PAGE_BUDGET); // The newest fork pages alone: the oversize one would bust the page beside it. - let (first, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50))); + let (first, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50))); assert_eq!(ids(&first), [fork(3)]); assert_eq!(first.next_cursor.map(ForkCursor::order), Some(2)); - let response = mind.desk_forks(&s(LIST[0]), &list(&LIST), None, first.next_cursor, limit(50)); + let response = mind.desk_forks(&account(LIST[0]), &list(&LIST), None, first.next_cursor, limit(50)); let DeskResponse::NotStored(refusal) = response else { panic!("{response:?}") }; assert_eq!(refusal.code, ForkRefusalCode::ForkTooLarge); let order = 1; assert_eq!(refusal.error, fork_too_large(&fork(2), order).error); assert!(refusal.error.contains(&fork(2)) && refusal.error.contains("order 1"), "{refusal:?}"); // The order the refusal names is the order of the fork it names; its cursor goes on past it. - assert_eq!(entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(2))).0.card.order, order); - let (rest, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, ForkCursor::new(order), limit(50))); + assert_eq!(entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(2))).0.card.order, order); + let (rest, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, ForkCursor::new(order), limit(50))); assert_eq!((ids(&rest), rest.next_cursor), (vec![fork(1)], None)); } @@ -1499,13 +1687,13 @@ mod tests { fn a_rank_is_stable_when_more_questions_are_admitted() { let mut mind = seeded(); committed(admit(&mut mind, (1..=5).map(question_n).collect())); - let (first, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(2))); + let (first, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(2))); assert_eq!((orders(&first), first.next_cursor.map(ForkCursor::order)), (vec![4, 3], Some(3))); let held = first.next_cursor; - let (before, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, held, limit(50))); + let (before, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, held, limit(50))); committed(admit(&mut mind, vec![question_n(6)])); committed(admit(&mut mind, vec![question_n(0)])); - let (after, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, held, limit(50))); + let (after, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, held, limit(50))); assert_eq!((after.forks, after.next_cursor), (before.forks, before.next_cursor), "the cursor from before resumes the same forks"); let everything = all(&mind, 50); assert_eq!(ids(&everything), [fork(0), fork(6), fork(5), fork(4), fork(3), fork(2), fork(1)]); @@ -1534,9 +1722,9 @@ mod tests { admit_unshowable(&mut mind, "Q2"); committed(admit(&mut mind, vec![question_n(3)])); for who in [&LIST[..], &["x"][..], &["z"][..]] { - let (shown, _) = page(mind.desk_forks(&s(LIST[0]), &list(who), None, None, limit(50))); + let (shown, _) = page(mind.desk_forks(&account(who[0]), &list(who), None, None, limit(50))); assert_eq!((ids(&shown), orders(&shown)), (vec![fork(3), fork(1)], vec![2, 0]), "{who:?}: rank 1 is the gap"); - assert_eq!(entry(mind.desk_fork(&s(LIST[0]), &list(who), &fork(3))).0.card.order, 2, "{who:?}"); + assert_eq!(entry(mind.desk_fork(&account(who[0]), &list(who), &fork(3))).0.card.order, 2, "{who:?}"); } committed(admit(&mut mind, vec![question_n(4)])); assert_eq!(orders(&all(&mind, 50)), [3, 2, 0]); @@ -1549,11 +1737,11 @@ mod tests { let mut mind = desk(2); for n in [1, 2] { grown_past(&mut mind, n, DESK_PAGE_BUDGET * 55 / 100); - assert!(bare(&mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(n))) <= DESK_PAGE_BUDGET, "each fork alone fits"); + assert!(bare(&mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(n))) <= DESK_PAGE_BUDGET, "each fork alone fits"); } - let (one, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, None, limit(50))); + let (one, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, None, limit(50))); assert_eq!((ids(&one), one.next_cursor.map(ForkCursor::order)), (vec![fork(2)], Some(1))); - let (rest, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, one.next_cursor, limit(50))); + let (rest, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, one.next_cursor, limit(50))); assert_eq!((ids(&rest), rest.next_cursor), (vec![fork(1)], None)); } @@ -1584,9 +1772,9 @@ mod tests { assert_eq!(after.len(), 70); assert_eq!(after.iter().map(|(_, order)| *order).collect::>(), (0..70).collect()); // The page boundary at c1: the page ending at order 1 names c1, and the next page is order 0 alone. - let (upper, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, ForkCursor::new(3), limit(2))); + let (upper, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, ForkCursor::new(3), limit(2))); assert_eq!((orders(&upper), upper.next_cursor.map(ForkCursor::order)), (vec![2, 1], Some(1))); - let (last, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), None, upper.next_cursor, limit(50))); + let (last, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), None, upper.next_cursor, limit(50))); assert_eq!((orders(&last), last.next_cursor), (vec![0], None)); } @@ -1670,7 +1858,7 @@ mod tests { let mut mind = desk(3); close(&mut mind, &fork(1), "R9"); close(&mut mind, &fork(2), "R2"); - let named = |n: u32| entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(n))).0.card.ruling; + let named = |n: u32| entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(n))).0.card.ruling; assert_eq!(named(1).as_deref(), Some("eureka-state:ruling:R9")); assert_eq!(named(2).as_deref(), Some("eureka-state:ruling:R2")); assert_eq!(named(3), None); @@ -1721,6 +1909,9 @@ mod tests { } for (order, entry) in &held { let key = fork(*order as u32); + if scenario.get("twin").is_some() { + list_it(&mut mind, *order as u32); + } let answers = entry["record"]["answers"].as_array().unwrap(); let hash = hash_of(&mind, &key); if !answers.is_empty() { @@ -1741,7 +1932,7 @@ mod tests { answered_at: "2026-09-16T11:00:00Z".into(), request_id: request_id(), }; - mind.desk_answer(&list(&LIST), s("x"), DeskAuthority::Member, retraction, now()); + mind.desk_answer(&list(&LIST), account("x"), DeskAuthority::Member, retraction, now()); } else { answer(&mut mind, "y", &LIST, &key, &hash, None); } @@ -1753,7 +1944,7 @@ mod tests { let (request, want) = (&step["request"], &step["response"]); let status: Option = serde_json::from_value(request["status"].clone()).unwrap(); let cursor: Option = serde_json::from_value(request["cursor"].clone()).unwrap(); - let (got, _) = page(mind.desk_forks(&s(subject), &list(&["x"]), status, cursor, limit(request["limit"].as_u64().unwrap() as u8))); + let (got, _) = page(mind.desk_forks(&account(subject), &list(&["x"]), status, cursor, limit(request["limit"].as_u64().unwrap() as u8))); let name = &scenario["name"]; assert_eq!((got.open_count, got.recorded_count), (want["open_count"].as_u64().unwrap(), want["recorded_count"].as_u64().unwrap()), "{name}"); assert_eq!(serde_json::to_value(got.next_cursor).unwrap(), want["next_cursor"], "{name}"); @@ -1802,7 +1993,7 @@ mod tests { // Every third fork is closed; the batch's ranks follow its keys, so the closed ranks are read, not computed. let mut closed = std::collections::BTreeSet::new(); for n in (1..=24).step_by(3) { - closed.insert(entry(mind.desk_fork(&s(LIST[0]), &list(&LIST), &fork(n))).0.card.order); + closed.insert(entry(mind.desk_fork(&account(LIST[0]), &list(&LIST), &fork(n))).0.card.order); close(&mut mind, &fork(n), &format!("R{n}")); } for status in [None, Some(ListStatus::Open), Some(ListStatus::Recorded)] { @@ -1815,7 +2006,7 @@ mod tests { for size in 1..=24_u8 { let (mut cursor, mut walked, mut pages) = (None, Vec::new(), 0); loop { - let (page, _) = page(mind.desk_forks(&s(LIST[0]), &list(&LIST), status, cursor, limit(size))); + let (page, _) = page(mind.desk_forks(&account(LIST[0]), &list(&LIST), status, cursor, limit(size))); pages += 1; assert!(page.forks.len() <= size as usize && pages < 100); walked.extend(orders(&page)); diff --git a/crates/huginn-mind/src/docs.rs b/crates/huginn-mind/src/docs.rs index d191a6a..58e6851 100644 --- a/crates/huginn-mind/src/docs.rs +++ b/crates/huginn-mind/src/docs.rs @@ -401,7 +401,8 @@ pub(crate) fn line_of(document: &PipelineDocument) -> Option> { | D::Pulse(_) | D::Reply(_) | D::Thought(_) - | D::DeskAnswer(_) => None, + | D::DeskAnswer(_) + | D::DeskListing(_) => None, } } @@ -455,10 +456,11 @@ pub enum CitationRole { DayThrough, Fork, Answer, + Listed, } impl CitationRole { - pub const ALL: [CitationRole; 26] = [ + pub const ALL: [CitationRole; 27] = [ Self::RaisedIn, Self::Answers, Self::Rulings, @@ -485,6 +487,7 @@ impl CitationRole { Self::DayThrough, Self::Fork, Self::Answer, + Self::Listed, ]; /// The wire spelling, equal to the referring field's name. @@ -516,6 +519,7 @@ impl CitationRole { Self::DayThrough => "day_through", Self::Fork => "fork", Self::Answer => "answer", + Self::Listed => "listed", } } @@ -543,6 +547,7 @@ impl CitationRole { Self::Run => K::Reply, Self::Reads => K::Thought, Self::Fork | Self::Answer => K::DeskAnswer, + Self::Listed => K::DeskListing, } } @@ -552,7 +557,7 @@ impl CitationRole { pub fn target_kinds(self) -> Vec { use PipelineKind as K; match self { - Self::Answers | Self::Questions | Self::Forks | Self::Fork => vec![K::Question], + Self::Answers | Self::Questions | Self::Forks | Self::Fork | Self::Listed => vec![K::Question], Self::Rulings => vec![K::Ruling], Self::CutSpec => vec![K::CutSpec], Self::CutReport => vec![K::CutReport], @@ -670,6 +675,7 @@ pub(crate) fn citations(document: &PipelineDocument) -> Vec<(CitationRole, Pipel DeskOutcome::Counted | DeskOutcome::Refused { .. } => {} } } + D::DeskListing(listing) => edges.push((R::Listed, cited(K::Question, &listing.listed))), D::Campaign(_) | D::Target(_) | D::Instance(_) | D::Stewardship(_) | D::Pulse(_) => {} } edges diff --git a/crates/huginn-mind/src/envelope.rs b/crates/huginn-mind/src/envelope.rs index 2759b71..ca88f36 100644 --- a/crates/huginn-mind/src/envelope.rs +++ b/crates/huginn-mind/src/envelope.rs @@ -408,9 +408,9 @@ mod tests { fn desk_answer() -> DeskRequest { DeskRequest::Answer { mind: slug(INSTANCE), - subject: crate::fixtures::s("user-7"), + subject: crate::fixtures::acct("user-7"), authority: DeskAuthority::Member, - deciders: vec![crate::fixtures::s("user-7")], + deciders: vec![crate::fixtures::acct("user-7")], request: ForkAnswerRequest { schema_version: crate::desk_wire::RequestSchema::V0, fork_id: "eureka-state:question:Q1".into(), @@ -425,7 +425,7 @@ mod tests { } fn desk_requests() -> [(DeskRequest, &'static str); 3] { - let (mind, subject, deciders) = (slug(INSTANCE), crate::fixtures::s("user-7"), vec![crate::fixtures::s("user-7")]); + let (mind, subject, deciders) = (slug(INSTANCE), crate::fixtures::acct("user-7"), vec![crate::fixtures::acct("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"), @@ -579,13 +579,13 @@ mod tests { 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 deciders: Vec<_> = (0..64).map(|at| crate::fixtures::acct(&format!("decider {at}"))).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)), + subject: crate::fixtures::acct("the widest subject"), authority, deciders: deciders.clone(), request: ForkAnswerRequest { @@ -596,10 +596,10 @@ mod tests { }, }, DeskRequest::Forks { mind, status, cursor, limit, .. } => { - DeskRequest::Forks { mind, subject: crate::fixtures::s(&long(200)), deciders: deciders.clone(), status, cursor, limit } + DeskRequest::Forks { mind, subject: crate::fixtures::acct("the widest subject"), 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) } + DeskRequest::Fork { mind, subject: crate::fixtures::acct("the widest subject"), deciders: deciders.clone(), fork_id: long(200) } } }; let message = encode_desk_request("m-10", &widest, Some(long(200))).unwrap(); @@ -607,7 +607,8 @@ mod tests { 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:?}"); + // 64 account ids of 36 bytes and a 4,000-byte note: the widest answer a request can carry. + assert!(sized[0] > 4000 + 64 * 36, "the test builds a request that is actually large: {sized:?}"); let answer = ForkDeskAnswer { schema_version: crate::desk_wire::AnswerSchema::V0, diff --git a/crates/huginn-mind/src/fixtures.rs b/crates/huginn-mind/src/fixtures.rs index 7620c15..b7005b5 100644 --- a/crates/huginn-mind/src/fixtures.rs +++ b/crates/huginn-mind/src/fixtures.rs @@ -56,6 +56,21 @@ pub(crate) fn s(value: &str) -> Short { value.into() } +/// A deterministic Bifrost account id for a test's name for someone: a lowercase D-format UUID. +pub(crate) fn account(name: &str) -> Short { + // An id already in account form stands for itself. + if name.len() == 36 && name.bytes().all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f' | b'-')) { + return Short(name.into()); + } + let hash = name.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)); + Short(format!("{:08x}-{:04x}-4000-8000-{:012x}", hash >> 32, (hash >> 16) & 0xffff, hash & 0xffff_ffff_ffff)) +} + +/// The same, as the stored type. +pub(crate) fn acct(name: &str) -> eureka_pipeline::AccountId { + eureka_pipeline::AccountId(account(name).0) +} + pub(crate) fn l(value: &str) -> Label { value.into() } diff --git a/crates/huginn-mind/src/index.rs b/crates/huginn-mind/src/index.rs index 13ccff1..bc0d53d 100644 --- a/crates/huginn-mind/src/index.rs +++ b/crates/huginn-mind/src/index.rs @@ -105,8 +105,8 @@ pub fn index_text(document: &PipelineDocument) -> Option { lines.push(&idea.idea.0); lines.push(&idea.tradeoffs.0); } - // A desk answer is a person's words, not indexed. - D::Instance(_) | D::Stewardship(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::DeskAnswer(_) => {} + // A desk answer is a person's words and a listing's reason is no retrieval text: neither is indexed. + D::Instance(_) | D::Stewardship(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::DeskAnswer(_) | D::DeskListing(_) => {} } lines.retain(|line| !line.is_empty()); if lines.is_empty() { diff --git a/crates/huginn-mind/src/query.rs b/crates/huginn-mind/src/query.rs index eeb8ee7..6bb8032 100644 --- a/crates/huginn-mind/src/query.rs +++ b/crates/huginn-mind/src/query.rs @@ -166,6 +166,8 @@ pub enum PipelineFacts { /// the one that counts for its fork at the read's snapshot, derived by `counted_answer` and never /// stored: a retracted `Counted` answer reads `counts: false`, the promoted next answer `true`. DeskAnswer { fork: Short, sequence: u32, outcome: Short, counts: bool, authority: DeskAuthority }, + /// The question this listing lets members read. + DeskListing { listed: Short }, } impl PipelineFacts { @@ -306,6 +308,7 @@ impl PipelineFacts { counts: counted_of(&value.fork.0).as_deref() == Some(id.id.0.as_str()), authority: value.authority, }, + D::DeskListing(value) => Self::DeskListing { listed: value.listed.clone() }, } } } @@ -1849,7 +1852,7 @@ mod tests { // Two desk answers on the open fork Q2: the second is a Revision of the first. let D::Question(shown) = question("Q2", &["A", "B"], "A") else { unreachable!() }; - let stamp = crate::desk::DeskStamp { answerer: s("x"), authority: eureka_pipeline::DeskAuthority::Member, deciders: vec![s("x")] }; + let stamp = crate::desk::DeskStamp { answerer: crate::fixtures::account("x"), authority: eureka_pipeline::DeskAuthority::Member, deciders: vec![crate::fixtures::account("x")] }; let pick = |n: u32| crate::desk::DeskAnswerRequest { fork: s(&q2), options_hash: crate::desk::options_hash(&shown), @@ -1862,6 +1865,10 @@ mod tests { assert!(matches!(mind.answer_desk(stamp.clone(), pick(n), now()), crate::desk::DeskAnswerOutcome::Stored { .. })); } + // The Mind lists Q2: one `listed` edge. + committed(admit(&mut mind, vec![D::DeskListing(eureka_pipeline::PipelineDeskListing { campaign: slug(CAMPAIGN), listed: s(&q2), reason: "Members may read why.".into() })])); + let listing = id("desk_listing", "Q2"); + let (n1, f1_closed, f2_closed) = ( id("resolution", "question.Q1.n1"), id("resolution", &format!("finding.{}.n1", f1.rsplit(':').next().unwrap())), @@ -1910,8 +1917,9 @@ mod tests { (CitationRole::Fork, &answer_1, &q2), (CitationRole::Fork, &answer_2, &q2), (CitationRole::Answer, &answer_2, &answer_1), + (CitationRole::Listed, &listing, &q2), ]; - assert_eq!(CitationRole::ALL.len(), 26); + assert_eq!(CitationRole::ALL.len(), 27); for role in CitationRole::ALL { let selection = Selection { cited: Some(Incoming { role: role.name().into(), exists: true }), ..Selection::default() }; diff --git a/crates/huginn-mind/src/rows.rs b/crates/huginn-mind/src/rows.rs index 852fecc..9e72339 100644 --- a/crates/huginn-mind/src/rows.rs +++ b/crates/huginn-mind/src/rows.rs @@ -248,7 +248,7 @@ impl SelectionRow { 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(_) => { + 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(_) | D::DeskListing(_) => { Vec::new() } }, @@ -466,7 +466,7 @@ mod tests { /// refused at the substrate's door. #[test] fn each_kind_declares_exactly_its_aliases_and_none_is_numeric() { - let table: [(K, &[&str]); 21] = [ + let table: [(K, &[&str]); 22] = [ (K::Campaign, &["repo"]), (K::Target, &[]), (K::Question, &[]), @@ -488,6 +488,7 @@ mod tests { (K::Reply, &[]), (K::Thought, &[]), (K::DeskAnswer, &[]), + (K::DeskListing, &[]), ]; for (kind, extra) in table { let want = [&["root", "in_force", "faculty"][..], extra].concat(); @@ -503,7 +504,7 @@ mod tests { /// twenty-one. #[test] fn each_kind_declares_the_roles_it_carries_and_their_targets() { - let table: [(K, &[&str]); 21] = [ + let table: [(K, &[&str]); 22] = [ (K::Campaign, &[]), (K::Target, &[]), (K::Question, &["raised_in"]), @@ -525,13 +526,14 @@ mod tests { (K::Reply, &["run"]), (K::Thought, &["reads"]), (K::DeskAnswer, &["fork", "answer"]), + (K::DeskListing, &["listed"]), ]; for (kind, roles) in table { assert_eq!(sorted(Vocabulary.declared_roles(kind.type_id())), strings(roles), "{kind:?}"); } assert_eq!(Vocabulary.target_leaves("answers"), vec![K::Question.type_id().to_string()]); assert_eq!(Vocabulary.target_leaves("rulings"), vec![K::Ruling.type_id().to_string()]); - assert_eq!(Vocabulary.target_leaves("subject").len(), 21); + assert_eq!(Vocabulary.target_leaves("subject").len(), 22); assert_eq!( sorted(Vocabulary.target_leaves("claims")), sorted([K::CutSpec, K::Finding, K::FollowUp].iter().map(|kind| kind.type_id().to_string()).collect()) diff --git a/crates/huginn-mind/tests/desk_feed_fixtures.rs b/crates/huginn-mind/tests/desk_feed_fixtures.rs index beb4aba..375e6dd 100644 --- a/crates/huginn-mind/tests/desk_feed_fixtures.rs +++ b/crates/huginn-mind/tests/desk_feed_fixtures.rs @@ -21,8 +21,8 @@ use huginn_mind::desk_wire::{ fork_too_large_read, AnswerSchema, CardSchema, CardStatus, DeskRequest, DeskResponse, EntrySchema, ForkCard, ForkCursor, ForkDeskAnswer, ForkEntry, ForkLimit, ForkList, ForkOption, ForkRecord, ForkRefusal, ForkRefusalCode, ForkRef, ListSchema, ListStatus, RecordSchema, }; -use huginn_mind::envelope::{OperationFailure, encode_desk_failure, encode_desk_request, encode_desk_response}; -use huginn_mind::eureka_pipeline::{DeskAuthority, Short, Slug}; +use huginn_mind::envelope::{OperationFailure, decode_desk_request, encode_desk_failure, encode_desk_request, encode_desk_response}; +use huginn_mind::eureka_pipeline::{AccountId, DeskAuthority, Short, Slug}; const RUNTIME: &str = "huginn-eureka"; /// Account ids as Bifrost sends them: `UserAccount.Id` rendered lowercase D-format @@ -42,11 +42,15 @@ fn s(text: &str) -> Short { Short(text.into()) } +fn a(text: &str) -> AccountId { + AccountId(text.into()) +} + fn forks(status: Option, cursor: Option, limit: u8) -> DeskRequest { DeskRequest::Forks { mind: Slug("eureka".into()), - subject: s(READER), - deciders: vec![s(READER), s(OTHER_DECIDER)], + subject: a(READER), + deciders: vec![a(READER), a(OTHER_DECIDER)], status, cursor: cursor.and_then(ForkCursor::new), limit: ForkLimit::new(limit).unwrap(), @@ -54,15 +58,15 @@ fn forks(status: Option, cursor: Option, limit: u8) -> DeskRequ } fn read() -> DeskRequest { - DeskRequest::Fork { mind: Slug("eureka".into()), subject: s(READER), deciders: vec![s(READER), s(OTHER_DECIDER)], fork_id: FORK.into() } + DeskRequest::Fork { mind: Slug("eureka".into()), subject: a(READER), deciders: vec![a(READER), a(OTHER_DECIDER)], fork_id: FORK.into() } } /// The same reads from a subject the call's deciders do not name: a non-decider. fn forks_as_non_decider() -> DeskRequest { DeskRequest::Forks { mind: Slug("eureka".into()), - subject: s(NON_DECIDER), - deciders: vec![s(READER), s(OTHER_DECIDER)], + subject: a(NON_DECIDER), + deciders: vec![a(READER), a(OTHER_DECIDER)], status: Some(ListStatus::Open), cursor: None, limit: ForkLimit::new(50).unwrap(), @@ -70,7 +74,7 @@ fn forks_as_non_decider() -> DeskRequest { } fn read_as_non_decider() -> DeskRequest { - DeskRequest::Fork { mind: Slug("eureka".into()), subject: s(NON_DECIDER), deciders: vec![s(READER), s(OTHER_DECIDER)], fork_id: FORK.into() } + DeskRequest::Fork { mind: Slug("eureka".into()), subject: a(NON_DECIDER), deciders: vec![a(READER), a(OTHER_DECIDER)], fork_id: FORK.into() } } fn list(open_count: u64, recorded_count: u64) -> DeskResponse { @@ -196,6 +200,24 @@ fn hex(bytes: &[u8]) -> String { text } +/// A request message whose payload is `request` with one field changed to something the typed request +/// cannot hold: what a client that skips the account grammar would send. +fn malformed(id: &str, request: &DeskRequest, edit: impl Fn(&mut serde_json::Value)) -> CultNetMessage { + use base64::Engine; + let mut value = serde_json::to_value(request).unwrap(); + edit(&mut value); + CultNetMessage::OperationRequest { + message_id: id.into(), + service_id: huginn_mind::desk_wire::DESK_SERVICE_ID.into(), + operation: "fork".into(), + payload_schema: huginn_mind::desk_wire::DESK_REQUEST_SCHEMA.into(), + payload_encoding: "messagepack-base64".into(), + payload: base64::engine::general_purpose::STANDARD.encode(rmp_serde::to_vec_named(&value).unwrap()), + source_runtime_id: None, + target_runtime_id: None, + } +} + /// Every fixture: its file stem and its bytes. A message gives `.frame.bin`, `.frame.hex` and, /// for an operation message, `.payload.msgpack` and `.payload.hex`; a bare payload gives the last two. fn fixtures() -> Vec<(String, Vec)> { @@ -221,6 +243,15 @@ fn fixtures() -> Vec<(String, Vec)> { // raised_at nil: the question's first admission is unknown. message("read.response", encode_desk_response("m-4", "fork", &DeskResponse::Fork { entry: entry(true, None), as_of: 10190 }, RUNTIME).unwrap()); message("read-non-decider.response", encode_desk_response("m-6", "fork", &DeskResponse::Fork { entry: entry(false, None), as_of: 10190 }, RUNTIME).unwrap()); + // A decider or subject that is no account id is refused at decode, naming the field. + for (name, id, request) in [ + ("read-mxid-decider", "m-7", malformed("m-7", &read(), |value| value["Fork"]["deciders"][0] = "@heimdall:gamecult.org".into())), + ("read-email-subject", "m-8", malformed("m-8", &read(), |value| value["Fork"]["subject"] = "meta@gamecult.org".into())), + ] { + message(&format!("{name}.request"), request.clone()); + let failure = decode_desk_request(&request).expect_err("the door refuses it"); + message(&format!("{name}.failure.response"), encode_desk_failure(id, "fork", &failure, RUNTIME)); + } message("list-empty.response", encode_desk_response("m-1", "forks", &list(0, 0), RUNTIME).unwrap()); message("list-counts.response", encode_desk_response("m-3", "forks", &list(3, 5), RUNTIME).unwrap()); message( @@ -290,8 +321,7 @@ fn account_id(text: &str) -> bool { } /// Every account id the fixtures carry is a Bifrost account id, never a Matrix id, email or -/// handle. The door does not refuse other shapes yet (`Short`, any 200 bytes); that refusal is owed -/// by `cut-desk-feed-listing.r6`, and this pins the grammar it must enforce. +/// handle, and the door refuses any other shape at decode, naming the field and never the value. #[test] fn every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid() { for request in [forks(Some(ListStatus::Open), None, 50), read()] { @@ -302,6 +332,14 @@ fn every_account_id_in_the_fixtures_is_a_lowercase_d_format_uuid() { assert!(request.deciders().iter().all(|decider| account_id(&decider.0)), "deciders"); } assert!(record(true).answers.iter().all(|answer| account_id(answer.answerer.as_deref().unwrap())), "answerer"); + for (request, field, value) in [ + (malformed("m-7", &read(), |value| value["Fork"]["deciders"][0] = "@heimdall:gamecult.org".into()), "deciders", "@heimdall:gamecult.org"), + (malformed("m-8", &read(), |value| value["Fork"]["subject"] = "meta@gamecult.org".into()), "subject", "meta@gamecult.org"), + ] { + let failure = decode_desk_request(&request).expect_err("a request outside the grammar is refused"); + assert_eq!(failure.code, "payload-not-a-request"); + assert!(failure.message.contains(field) && !failure.message.contains(value), "{}", failure.message); + } for shape in [ "@heimdall:gamecult.org", "meta@gamecult.org", diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/README.md b/crates/huginn-mind/tests/fixtures/desk-feed/README.md index fc47d17..7d33917 100644 --- a/crates/huginn-mind/tests/fixtures/desk-feed/README.md +++ b/crates/huginn-mind/tests/fixtures/desk-feed/README.md @@ -15,9 +15,9 @@ Each case has up to four files: Hex is lowercase with one trailing newline. The fork id is a question Eureka's mind holds. The record's answers, notes and hash are synthetic. -**The listing gate is not live.** On `main` every showable question goes to every reader. Do not -put a Mind that holds private questions behind a live receiver before `cut-desk-feed-listing` -lands (see `docs/desk-feed-door.md`). +**The listing gate is live in Huginn.** A reader who is not on the call's deciders sees only forks the +Mind lists (`docs/desk-feed-door.md`); `list-empty.response` is what such a reader gets from a Mind with +nothing listed. | case | direction | content | |---|---|---| @@ -31,6 +31,10 @@ lands (see `docs/desk-feed-door.md`). | `list-open-non-decider.response` | Huginn to Bifrost | the same page for a non-decider: `deciders` empty, `decider_count` 2, every `answerer` nil, `answerer_count` 3; no account id anywhere in its bytes | | `read.response` | Huginn to Bifrost | a decider's read of one fork, `raised_at` nil | | `read-non-decider.response` | Huginn to Bifrost | the same read for a non-decider, `raised_at` nil, no account id | +| `read-mxid-decider.request` | client to Huginn | a `Fork` whose first decider is a Matrix id, so outside the account grammar; refused at decode | +| `read-mxid-decider.failure.response` | Huginn to Bifrost | the operation failure for it: `payload-not-a-request`, naming `deciders`, never quoting the id | +| `read-email-subject.request` | client to Huginn | a `Fork` whose subject is an email address; refused at decode | +| `read-email-subject.failure.response` | Huginn to Bifrost | the operation failure for it, naming `subject` | | `list-empty.response` | Huginn to Bifrost | an empty Mind, or nothing listed to this reader: counts 0, `forks: []` | | `list-counts.response` | Huginn to Bifrost | the limit-0 answer: open 3, recorded 5, `forks: []` | | `read-unknown.response` | Huginn to Bifrost | `NotStored` `fork_unknown` (Bifrost: 404) | diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.frame.bin b/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.frame.bin new file mode 100644 index 0000000000000000000000000000000000000000..572df81ef9a2f390dba00c8a6b36b440133049c8 GIT binary patch literal 375 zcmZXQ&rZTX5XSLgJhtJFA$stF0fG_|Ahwi?PIrg0w7Xk&b_wzb-h2j)N(dn_@#ur_ zHWnpbyv!WF-^};Tlu9S`xZwg4rO66LPoFtFWE!}qwDrI+ zNeBZPUp3ls?b>uzuM){Ti)#IPr=2-2TLi_9L+9b`gRz$q(ap>2OP4-117Ude)W^los5%p(5=yC#-q literal 0 HcmV?d00001 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.frame.hex b/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.frame.hex new file mode 100644 index 0000000..da061ec --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.frame.hex @@ -0,0 +1 @@ +000001738aab646961676e6f737469637391b57061796c6f61642d6e6f742d612d72657175657374a96d6573736167654964a36d2d38a96f7065726174696f6ea4666f726ba77061796c6f6164d9606771526a6232526c745842686557787659575174626d39304c574574636d56786457567a644b64745a584e7a5957646c3253466d615756735a43424762334a724c6e4e31596d706c5933513649476c75646d467361575174646d46736457553daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61d92567616d6563756c742e63756c746e65742e6f7065726174696f6e5f6661696c7572652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.payload.hex new file mode 100644 index 0000000..6980f2f --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.payload.hex @@ -0,0 +1 @@ +82a4636f6465b57061796c6f61642d6e6f742d612d72657175657374a76d657373616765d9216669656c6420466f726b2e7375626a6563743a20696e76616c69642d76616c7565 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.payload.msgpack new file mode 100644 index 0000000..7cb24a7 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.failure.response.payload.msgpack @@ -0,0 +1 @@ +‚¤codeµpayload-not-a-request§messageŁ!field Fork.subject: invalid-value \ No newline at end of file diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.frame.bin b/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.frame.bin new file mode 100644 index 0000000000000000000000000000000000000000..ac0da09dd7877fdc2b2c8ea8e0e5495659229e74 GIT binary patch literal 433 zcmYk3O-{ow5QWQusFL(YY?0cy4RKJ@Hg3E@j_uZI;v|jTqT~V`1SC|U3W)_PF2Y?{ zkfI1!&di&qdEaz87pI4#N%yj z5eY_Kfk9Bwfs36{AR&NNxV2MvA%q8J+5@l-V`G39Ou7qOKsl|$+D|;Rw6IPR2&Mx( zVkxx7+LN$?bHl*fW#2{Pdpw%yTeR%sEfb!cn&-X3!Urn#nxF-SATX&6%5ZMh3^_HM zID&!YkgkH1_WUBU`ore7npCDDE6*Tkkh#b<84!&}YH;G~rk515^&#x+vuiRL3YjSb z_BFO_-PC3@neCg**=tK-oY9=c+2*Z9RuGz8Mnv&p|I|w6RB%b&#eNfKS(kB@+?9N{ n=Sp`E=1Kib174~I@CV(eI^(+gi~LV&92GAQF>Su`^3UrVq0OP{ literal 0 HcmV?d00001 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.frame.hex b/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.frame.hex new file mode 100644 index 0000000..6be8caa --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.frame.hex @@ -0,0 +1 @@ +000001ad87a96d6573736167654964a36d2d38a96f7065726174696f6ea4666f726ba77061796c6f6164d9fc6761524762334a72684b686b5a574e705a47567963354c5a4a44566d4d474d79596a466c4c5468684e4751744e474d7a596930355a54646d4c5446684d6d497a597a526b4e5755325a746b6b4d4749355a44646c4d3245744e6d4d794d5330305a6a55344c5745775a4451744f5755345a6a64684e6d4931597a526b70325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30656152746157356b706d5631636d56725961647a64574a715a574e307357316c644746415a3246745a574e316248517562334a6eaf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.payload.hex new file mode 100644 index 0000000..dea0782 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.payload.hex @@ -0,0 +1 @@ +81a4466f726b84a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a46d696e64a6657572656b61a77375626a656374b16d6574614067616d6563756c742e6f7267 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.payload.msgpack new file mode 100644 index 0000000..9c69e5d --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read-email-subject.request.payload.msgpack @@ -0,0 +1 @@ +¤Fork„Ødeciders’Ł$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6fŁ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d§fork_idŁ0eureka-substrate:question:desk-listing-authority¤mind¦eureka§subject±meta@gamecult.org \ No newline at end of file diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.frame.bin b/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.frame.bin new file mode 100644 index 0000000000000000000000000000000000000000..4c235e77158455fb4243f7637200c1205d46621c GIT binary patch literal 379 zcmZXQ%}T>S6ou=4Wrb zj2hgyxp#5C%Q@$EI(v5yYsN{gG+Iva@uMR5LKEgGZ9U={m^(nbl7L8ZxL{AxJ6Y)p z46$6R=czX3YkRU|`8=Fuy-?UBC?H-gCh?8UOD`h` z$48qM8PftJ+3n{<0(HXr-$Q}*|MK5X2^Y?Qzc|?74;_KQrr76y)BsjmVZ(M;2xh@) p^Nw}la;}um0Lv|EXDEbDSuPuG^Q(E#7?k7%EwK*Y49tL9U|(JGm{b4& literal 0 HcmV?d00001 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.frame.hex b/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.frame.hex new file mode 100644 index 0000000..081aeb9 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.frame.hex @@ -0,0 +1 @@ +000001778aab646961676e6f737469637391b57061796c6f61642d6e6f742d612d72657175657374a96d6573736167654964a36d2d37a96f7065726174696f6ea4666f726ba77061796c6f6164d9646771526a6232526c745842686557787659575174626d39304c574574636d56786457567a644b64745a584e7a5957646c3253566d615756735a43424762334a724c6d526c59326c6b5a584a7a577a42644f694270626e5a6862476c6b4c585a686248566caf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61d92567616d6563756c742e63756c746e65742e6f7065726174696f6e5f6661696c7572652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.payload.hex new file mode 100644 index 0000000..8f2ee85 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.payload.hex @@ -0,0 +1 @@ +82a4636f6465b57061796c6f61642d6e6f742d612d72657175657374a76d657373616765d9256669656c6420466f726b2e64656369646572735b305d3a20696e76616c69642d76616c7565 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.payload.msgpack new file mode 100644 index 0000000..44466de --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.failure.response.payload.msgpack @@ -0,0 +1 @@ +‚¤codeµpayload-not-a-request§messageŁ%field Fork.deciders[0]: invalid-value \ No newline at end of file diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.frame.bin b/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.frame.bin new file mode 100644 index 0000000000000000000000000000000000000000..97fa536cfa295acfab82a19941754c029ec318ba GIT binary patch literal 442 zcmYk3(N2Rf7=>rz>$nugW_IC?*+k~bGT5l}#+0_MPzsE)IraiRh$dz+#>5M+d=j5V zG(^4q&&ijQ@9%Vu-tP~JYfV<%VGru$^pF?4BvqVekGFX#p9)f^Iboki$1CF5QEyU; zD5c{M{z3nxst$}z&dCkqpAsj6-XT3~1g230{B`o^B2$(~(K1hba|dL5*+=M|A@ z4VoQ$QX86FyMUm6HboZ(vqi&50ViJ7++?9z4q@M%b+aFs{@lRHc>y{U&|unQNH7@82^QINVSJbP_*j5C_EI9t87?g~QXNklXsSTAkGr-Cc;E;g$; v%estfc~kQBhU==kHD2^j8t_s#ra$N=tukJ9f8GC8!9nwK8`I`J&;M9o+peQ7 literal 0 HcmV?d00001 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.frame.hex b/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.frame.hex new file mode 100644 index 0000000..45c3d67 --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.frame.hex @@ -0,0 +1 @@ +000001b687a96d6573736167654964a36d2d37a96f7065726174696f6ea4666f726ba77061796c6f6164da01046761524762334a72684b686b5a574e705a47567963354b325147686c6157316b595778734f6d64686257566a645778304c6d39795a396b6b4d4749355a44646c4d3245744e6d4d794d5330305a6a55344c5745775a4451744f5755345a6a64684e6d4931597a526b70325a76636d74666157545a4d475631636d56725953317a64574a7a64484a68644755366358566c63335270623234365a47567a6179317361584e306157356e4c57463164476876636d6c30656152746157356b706d5631636d56725961647a64574a715a574e30325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d593daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.payload.hex b/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.payload.hex new file mode 100644 index 0000000..23a3b6e --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.payload.hex @@ -0,0 +1 @@ +81a4466f726b84a8646563696465727392b6406865696d64616c6c3a67616d6563756c742e6f7267d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666 diff --git a/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.payload.msgpack b/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.payload.msgpack new file mode 100644 index 0000000..7e6151b --- /dev/null +++ b/crates/huginn-mind/tests/fixtures/desk-feed/read-mxid-decider.request.payload.msgpack @@ -0,0 +1 @@ +¤Fork„Ødeciders’¶@heimdall:gamecult.orgŁ$0b9d7e3a-6c21-4f58-a0d4-9e8f7a6b5c4d§fork_idŁ0eureka-substrate:question:desk-listing-authority¤mind¦eureka§subjectŁ$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f \ No newline at end of file diff --git a/docs/desk-feed-door.md b/docs/desk-feed-door.md index ffde232..fd278db 100644 --- a/docs/desk-feed-door.md +++ b/docs/desk-feed-door.md @@ -5,14 +5,13 @@ Status, 2026-11-11: the list and read wire described here is on `main` (`DeskReq 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). One part is -planned and not built: the listing gate that decides which forks a reader sees (cut spec -`cut-desk-feed-listing` in the `eureka-substrate` campaign). +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. -**Not live: the listing gate.** On `main` today every showable question is returned to every -reader, whatever the call's `deciders` say. The default of not listed, described under Listing below, -holds only once `cut-desk-feed-listing` lands. Until then, neither Bifrost's receiver nor the ops -root-run (`desk-door-ops-root-run`) may go live on a Mind that holds private questions. +**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/`. @@ -99,10 +98,12 @@ no `authority`, and a payload that names it is refused whole. `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; Huginn's door does not yet: it reads these -fields as `Short` (any text up to 200 bytes) and would accept `@x:server` or an email. A subject -is stored as `answerer` on an answer, so a wrong value would enter the mind. Refusing every other -shape at decode is owed by `cut-desk-feed-listing`. The test +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. @@ -226,7 +227,7 @@ fixtures carry it (`list-open.response` a string, `read.response` nil). 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; NOT live, see the status above) +## 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. @@ -248,10 +249,10 @@ Bifrost's feed applies no fork filter. 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 planned kind `desk_listing`, key `:desk_listing:`, +- **The listing record** is kind `desk_listing` (`epiphany.pipeline.desk_listing.v2`), key `:desk_listing:`, 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 door, so its receipt records who listed it and when. Unlisting is a + 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 `. No flag is stored anywhere else. diff --git a/schemas/cultnet/epiphany.pipeline.desk_answer.v2.schema.json b/schemas/cultnet/epiphany.pipeline.desk_answer.v2.schema.json index 3694e88..3beb904 100644 --- a/schemas/cultnet/epiphany.pipeline.desk_answer.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.desk_answer.v2.schema.json @@ -1,5 +1,11 @@ { "$defs": { + "AccountId": { + "description": "A Bifrost account id: a lowercase D-format UUID (`UserAccount.Id`). A Matrix id, an email or a\nhandle is never one.", + "maxLength": 36, + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + "type": "string" + }, "DeskAuthority": { "enum": [ "Operator", @@ -136,7 +142,7 @@ "$ref": "#/$defs/Short" }, "answerer": { - "$ref": "#/$defs/Short" + "$ref": "#/$defs/AccountId" }, "authority": { "$ref": "#/$defs/DeskAuthority" diff --git a/schemas/cultnet/epiphany.pipeline.desk_listing.v2.schema.json b/schemas/cultnet/epiphany.pipeline.desk_listing.v2.schema.json new file mode 100644 index 0000000..86b68ca --- /dev/null +++ b/schemas/cultnet/epiphany.pipeline.desk_listing.v2.schema.json @@ -0,0 +1,40 @@ +{ + "$defs": { + "Line": { + "description": "Text of at most 1,000 UTF-8 bytes.", + "maxLength": 1000, + "type": "string" + }, + "Short": { + "description": "Text of at most 200 UTF-8 bytes.", + "maxLength": 200, + "type": "string" + }, + "Slug": { + "description": "A campaign slug or key segment: label parts joined by `.`.", + "maxLength": 64, + "type": "string" + } + }, + "$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.desk_listing.v2.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A fork the Mind lists: members who are not on a call's decider list may read the question\nwhile this document is in force (unlist = a `Withdrawn` resolution on it; relist = withdraw\nthat). One per question, keyed by the question's label; the receipt says who listed it and\nwhen, `reason` why members may read it. Admitted on the Mind's own door only, never the desk's.", + "properties": { + "campaign": { + "$ref": "#/$defs/Slug" + }, + "listed": { + "$ref": "#/$defs/Short" + }, + "reason": { + "$ref": "#/$defs/Line" + } + }, + "required": [ + "campaign", + "listed", + "reason" + ], + "title": "PipelineDeskListing", + "type": "object" +} diff --git a/schemas/cultnet/epiphany.pipeline.finding.v2.schema.json b/schemas/cultnet/epiphany.pipeline.finding.v2.schema.json index c5731bf..207698c 100644 --- a/schemas/cultnet/epiphany.pipeline.finding.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.finding.v2.schema.json @@ -178,7 +178,8 @@ "Idea", "Reply", "Thought", - "DeskAnswer" + "DeskAnswer", + "DeskListing" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.follow_up.v2.schema.json b/schemas/cultnet/epiphany.pipeline.follow_up.v2.schema.json index e1c33ea..c2abb20 100644 --- a/schemas/cultnet/epiphany.pipeline.follow_up.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.follow_up.v2.schema.json @@ -182,7 +182,8 @@ "Idea", "Reply", "Thought", - "DeskAnswer" + "DeskAnswer", + "DeskListing" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.idea.v2.schema.json b/schemas/cultnet/epiphany.pipeline.idea.v2.schema.json index 0a310d0..f0a4828 100644 --- a/schemas/cultnet/epiphany.pipeline.idea.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.idea.v2.schema.json @@ -37,7 +37,8 @@ "Idea", "Reply", "Thought", - "DeskAnswer" + "DeskAnswer", + "DeskListing" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.memory.v2.schema.json b/schemas/cultnet/epiphany.pipeline.memory.v2.schema.json index 884814c..5aa5ed1 100644 --- a/schemas/cultnet/epiphany.pipeline.memory.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.memory.v2.schema.json @@ -154,7 +154,8 @@ "Idea", "Reply", "Thought", - "DeskAnswer" + "DeskAnswer", + "DeskListing" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.question.v2.schema.json b/schemas/cultnet/epiphany.pipeline.question.v2.schema.json index 1b06f45..63c9e20 100644 --- a/schemas/cultnet/epiphany.pipeline.question.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.question.v2.schema.json @@ -42,7 +42,8 @@ "Idea", "Reply", "Thought", - "DeskAnswer" + "DeskAnswer", + "DeskListing" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.reply.v2.schema.json b/schemas/cultnet/epiphany.pipeline.reply.v2.schema.json index fa0ddf6..e155d88 100644 --- a/schemas/cultnet/epiphany.pipeline.reply.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.reply.v2.schema.json @@ -32,7 +32,8 @@ "Idea", "Reply", "Thought", - "DeskAnswer" + "DeskAnswer", + "DeskListing" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.research.v2.schema.json b/schemas/cultnet/epiphany.pipeline.research.v2.schema.json index 390ba7d..97ea1dd 100644 --- a/schemas/cultnet/epiphany.pipeline.research.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.research.v2.schema.json @@ -104,7 +104,8 @@ "Idea", "Reply", "Thought", - "DeskAnswer" + "DeskAnswer", + "DeskListing" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.resolution.v2.schema.json b/schemas/cultnet/epiphany.pipeline.resolution.v2.schema.json index 224f289..d45ecb1 100644 --- a/schemas/cultnet/epiphany.pipeline.resolution.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.resolution.v2.schema.json @@ -37,7 +37,8 @@ "Idea", "Reply", "Thought", - "DeskAnswer" + "DeskAnswer", + "DeskListing" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json b/schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json index 2a816e5..57589c3 100644 --- a/schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.ruling.v2.schema.json @@ -75,7 +75,8 @@ "Idea", "Reply", "Thought", - "DeskAnswer" + "DeskAnswer", + "DeskListing" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.run.v2.schema.json b/schemas/cultnet/epiphany.pipeline.run.v2.schema.json index 38499f9..e1bb3e5 100644 --- a/schemas/cultnet/epiphany.pipeline.run.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.run.v2.schema.json @@ -32,7 +32,8 @@ "Idea", "Reply", "Thought", - "DeskAnswer" + "DeskAnswer", + "DeskListing" ], "type": "string" }, diff --git a/schemas/cultnet/epiphany.pipeline.thought.v2.schema.json b/schemas/cultnet/epiphany.pipeline.thought.v2.schema.json index d2a5893..c7d8b03 100644 --- a/schemas/cultnet/epiphany.pipeline.thought.v2.schema.json +++ b/schemas/cultnet/epiphany.pipeline.thought.v2.schema.json @@ -37,7 +37,8 @@ "Idea", "Reply", "Thought", - "DeskAnswer" + "DeskAnswer", + "DeskListing" ], "type": "string" }, diff --git a/schemas/cultnet/huginn.desk_request.v1.schema.json b/schemas/cultnet/huginn.desk_request.v1.schema.json index 49ee740..e06160e 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\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 `deciders`\nfeed the showability gate, and `subject` decides the view: a subject in `deciders` is a decider\nand is told the ids; any other is told counts in their place. `Forks` takes the contract's `list_request` fields flat\nbeside them (`status`, `cursor`, `limit`), each key written, `null` where absent. Bifrost\nstrips notes; see `DeskResponse` for the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the\nreads spec.", + "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 `deciders`\nfeed the showability gate, and `subject` decides the view: a subject in `deciders` is a decider,\nsees every showable fork and is told the ids; any other reads only the forks the Mind lists\n(a `desk_listing` in force) and is told counts in the ids' place. `Forks` takes the contract's `list_request` fields flat\nbeside them (`status`, `cursor`, `limit`), each key written, `null` where absent. Bifrost\nstrips notes; see `DeskResponse` for the trust boundary. These docs and the doc of `Mind::desk_forks`/`Mind::desk_fork` are the\nreads spec.", "oneOf": [ { "type": "object", @@ -15,7 +15,7 @@ "deciders": { "type": "array", "items": { - "$ref": "#/$defs/Short" + "$ref": "#/$defs/AccountId" }, "maxItems": 64, "minItems": 1, @@ -28,7 +28,7 @@ "$ref": "#/$defs/ForkAnswerRequest" }, "subject": { - "$ref": "#/$defs/Short" + "$ref": "#/$defs/AccountId" } }, "additionalProperties": false, @@ -59,7 +59,7 @@ "deciders": { "type": "array", "items": { - "$ref": "#/$defs/Short" + "$ref": "#/$defs/AccountId" }, "maxItems": 64, "minItems": 1, @@ -80,7 +80,7 @@ ] }, "subject": { - "$ref": "#/$defs/Short" + "$ref": "#/$defs/AccountId" } }, "additionalProperties": false, @@ -108,7 +108,7 @@ "deciders": { "type": "array", "items": { - "$ref": "#/$defs/Short" + "$ref": "#/$defs/AccountId" }, "maxItems": 64, "minItems": 1, @@ -121,7 +121,7 @@ "$ref": "#/$defs/Slug" }, "subject": { - "$ref": "#/$defs/Short" + "$ref": "#/$defs/AccountId" } }, "additionalProperties": false, @@ -140,6 +140,12 @@ } ], "$defs": { + "AccountId": { + "description": "A Bifrost account id: a lowercase D-format UUID (`UserAccount.Id`). A Matrix id, an email or a\nhandle is never one.", + "type": "string", + "maxLength": 36, + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, "DeskAuthority": { "type": "string", "enum": [ @@ -215,11 +221,6 @@ "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", diff --git a/schemas/cultnet/huginn.desk_response.v1.schema.json b/schemas/cultnet/huginn.desk_response.v1.schema.json index b6fd00a..d9bfc68 100644 --- a/schemas/cultnet/huginn.desk_response.v1.schema.json +++ b/schemas/cultnet/huginn.desk_response.v1.schema.json @@ -1682,7 +1682,8 @@ "Idea", "Reply", "Thought", - "DeskAnswer" + "DeskAnswer", + "DeskListing" ] }, "PipelineRefusal": { diff --git a/schemas/cultnet/huginn.mind_request.v1.schema.json b/schemas/cultnet/huginn.mind_request.v1.schema.json index db645c3..705a4e2 100644 --- a/schemas/cultnet/huginn.mind_request.v1.schema.json +++ b/schemas/cultnet/huginn.mind_request.v1.schema.json @@ -267,6 +267,12 @@ } ], "$defs": { + "AccountId": { + "description": "A Bifrost account id: a lowercase D-format UUID (`UserAccount.Id`). A Matrix id, an email or a\nhandle is never one.", + "type": "string", + "maxLength": 36, + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, "AuthorityMap": { "type": "object", "properties": { @@ -1320,7 +1326,7 @@ "$ref": "#/$defs/Short" }, "answerer": { - "$ref": "#/$defs/Short" + "$ref": "#/$defs/AccountId" }, "authority": { "$ref": "#/$defs/DeskAuthority" @@ -1390,6 +1396,26 @@ "outcome" ] }, + "PipelineDeskListing": { + "description": "A fork the Mind lists: members who are not on a call's decider list may read the question\nwhile this document is in force (unlist = a `Withdrawn` resolution on it; relist = withdraw\nthat). One per question, keyed by the question's label; the receipt says who listed it and\nwhen, `reason` why members may read it. Admitted on the Mind's own door only, never the desk's.", + "type": "object", + "properties": { + "campaign": { + "$ref": "#/$defs/Slug" + }, + "listed": { + "$ref": "#/$defs/Short" + }, + "reason": { + "$ref": "#/$defs/Line" + } + }, + "required": [ + "campaign", + "listed", + "reason" + ] + }, "PipelineDocument": { "description": "The envelope enum, adjacently tagged on the kind name: a document\non a wire is `{ kind, value }`, and `kind` is exactly what\n`PipelineKind::name()` returns, so a reader dispatches on the kind\nsegment it already knows from the key and needs no second registry\nmapping variant spellings to kinds. Adjacent rather than internal\nbecause a value is a named map of its own and an internal tag would\nhave to be merged into it; the tag stays beside the value instead.\nThis is not a published schema: `schemas/cultnet` publishes the\neighteen per-kind value documents, and the envelope is the wire's\nshape, not a document's.", "oneOf": [ @@ -1728,6 +1754,22 @@ "kind", "value" ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "desk_listing" + }, + "value": { + "$ref": "#/$defs/PipelineDeskListing" + } + }, + "required": [ + "kind", + "value" + ] } ] }, @@ -1995,7 +2037,8 @@ "Idea", "Reply", "Thought", - "DeskAnswer" + "DeskAnswer", + "DeskListing" ] }, "PipelineMemory": { diff --git a/schemas/cultnet/huginn.mind_response.v1.schema.json b/schemas/cultnet/huginn.mind_response.v1.schema.json index 2a2b6d9..30fc70a 100644 --- a/schemas/cultnet/huginn.mind_response.v1.schema.json +++ b/schemas/cultnet/huginn.mind_response.v1.schema.json @@ -179,6 +179,12 @@ } ], "$defs": { + "AccountId": { + "description": "A Bifrost account id: a lowercase D-format UUID (`UserAccount.Id`). A Matrix id, an email or a\nhandle is never one.", + "type": "string", + "maxLength": 36, + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, "AdmissionFacts": { "description": "What admission recorded about a document when it landed: the receipt that\nwrote it, the `now` that receipt was taken at, who asked, and the\nreceipt's ordinal, which is the read side's order and its snapshot\nposition.", "type": "object", @@ -430,7 +436,8 @@ "reads", "day_through", "fork", - "answer" + "answer", + "listed" ] }, "ClaimOutcome": { @@ -2870,7 +2877,7 @@ "$ref": "#/$defs/Short" }, "answerer": { - "$ref": "#/$defs/Short" + "$ref": "#/$defs/AccountId" }, "authority": { "$ref": "#/$defs/DeskAuthority" @@ -2940,6 +2947,26 @@ "outcome" ] }, + "PipelineDeskListing": { + "description": "A fork the Mind lists: members who are not on a call's decider list may read the question\nwhile this document is in force (unlist = a `Withdrawn` resolution on it; relist = withdraw\nthat). One per question, keyed by the question's label; the receipt says who listed it and\nwhen, `reason` why members may read it. Admitted on the Mind's own door only, never the desk's.", + "type": "object", + "properties": { + "campaign": { + "$ref": "#/$defs/Slug" + }, + "listed": { + "$ref": "#/$defs/Short" + }, + "reason": { + "$ref": "#/$defs/Line" + } + }, + "required": [ + "campaign", + "listed", + "reason" + ] + }, "PipelineDocument": { "description": "The envelope enum, adjacently tagged on the kind name: a document\non a wire is `{ kind, value }`, and `kind` is exactly what\n`PipelineKind::name()` returns, so a reader dispatches on the kind\nsegment it already knows from the key and needs no second registry\nmapping variant spellings to kinds. Adjacent rather than internal\nbecause a value is a named map of its own and an internal tag would\nhave to be merged into it; the tag stays beside the value instead.\nThis is not a published schema: `schemas/cultnet` publishes the\neighteen per-kind value documents, and the envelope is the wire's\nshape, not a document's.", "oneOf": [ @@ -3278,6 +3305,22 @@ "kind", "value" ] + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "desk_listing" + }, + "value": { + "$ref": "#/$defs/PipelineDeskListing" + } + }, + "required": [ + "kind", + "value" + ] } ] }, @@ -4251,6 +4294,31 @@ "kind", "value" ] + }, + { + "description": "The question this listing lets members read.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "desk_listing" + }, + "value": { + "type": "object", + "properties": { + "listed": { + "$ref": "#/$defs/Short" + } + }, + "required": [ + "listed" + ] + } + }, + "required": [ + "kind", + "value" + ] } ] }, @@ -4518,7 +4586,8 @@ "Idea", "Reply", "Thought", - "DeskAnswer" + "DeskAnswer", + "DeskListing" ] }, "PipelineMemory": { diff --git a/schemas/cultnet/index.json b/schemas/cultnet/index.json index d2a7fcc..7bb5aaa 100644 --- a/schemas/cultnet/index.json +++ b/schemas/cultnet/index.json @@ -243,6 +243,17 @@ "title": "Epiphany Pipeline DeskAnswer v2", "path": "epiphany.pipeline.desk_answer.v2.schema.json" }, + { + "schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.desk_listing.v2.schema.json", + "kind": "document_payload", + "wireContracts": [ + "cultnet.schema.v0" + ], + "schemaVersion": "epiphany.pipeline.desk_listing.v2", + "documentType": "epiphany.pipeline.desk_listing.v2", + "title": "Epiphany Pipeline DeskListing v2", + "path": "epiphany.pipeline.desk_listing.v2.schema.json" + }, { "schemaId": "huginn.mind_request.v1", "kind": "wire_message",