Merge pull request 'Desk answers, Huginn half 1: desk_answer kind and the Mind counting door' (#29) from eureka-substrate/desk-answer-record into main

This commit is contained in:
Eureka 2026-11-10 17:51:53 +00:00
commit 7ea11b4419
33 changed files with 2925 additions and 51 deletions

16
Cargo.lock generated
View file

@ -961,6 +961,7 @@ dependencies = [
"serde_path_to_error",
"sha2",
"tempfile",
"unicode-normalization",
]
[[package]]
@ -1988,6 +1989,12 @@ dependencies = [
"syn 3.0.5",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.1"
@ -2073,6 +2080,15 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-normalization"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-segmentation"
version = "1.13.3"

View file

@ -11,6 +11,7 @@
# a failing test pass: a diff here is a wire change and needs its own ruling.
# cut_spec.reads-and-symbol and finding.location-symbol were appended after 44c010c added CodeLocation.symbol and PipelineCutSpec.reads (ruling context-pack-read-anchors); no earlier line changed.
# target.repos was appended after 9298371 added PipelineTarget.repos (ruling campaign-repo-set-home); no earlier line changed.
# sample.desk_answer was inserted after the sample corpus when the desk_answer kind was added (ruling eureka-substrate:ruling:desk-answer-kind-tagged-outcome); no earlier line changed.
sample.campaign eureka-state:campaign:self epiphany.pipeline.campaign.v2 EpiphanyPipelineCampaignDocument 9185a4736c7567ac657572656b612d7374617465a57469746c65b5457572656b6120706970656c696e65207374617465a57265706f7391b147616d6543756c742f4570697068616e79ae776f726b696e675f6272616e6368bb636f6465782f657572656b612d706970656c696e652d7374617465aa7461726765745f646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864
sample.target eureka-state:target:r2 epiphany.pipeline.target.v2 EpiphanyPipelineTargetDocument 9186a863616d706169676eac657572656b612d7374617465a87265766973696f6e02aa696e76617269616e74739182a56c6162656cab6d696e642d61646d697473a973746174656d656e74b64f6e6c792061646d697373696f6e207772697465732eac6e6f745f696e5f73636f706591ac4576652062726f7773696e67b963616e6f6e6963616c5f696d706c656d656e746174696f6e7391a743756c744c6962a3646f6384a470617468d9256e6f7465732f657572656b612d706970656c696e652d73746174652d7461726765742e6d64aa73746172745f6c696e6501a8656e645f6c696e6509a6636f6d6d6974a83566393832323864
sample.question eureka-state:question:Q1 epiphany.pipeline.question.v2 EpiphanyPipelineQuestionDocument 9189a863616d706169676eac657572656b612d7374617465a56c6162656ca25131a57469746c65b357686f206f776e73207468652073746174653fa87175657374696f6eb357686f206f776e73207468652073746174653fa76f7074696f6e739282a56c6162656ca141a474657874ab616e20696e7374616e636582a56c6162656ca142a474657874a661207265706fab7265636f6d6d656e646564a141a7646570656e647391a6437574203361a97261697365645f696e82a46b696e64a743757453706563a26964bf657572656b612d73746174653a6375745f737065633a6375742d33612e7231a861736b65645f6f6eaa323032362d30392d3135
@ -31,6 +32,7 @@ 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
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

View file

@ -272,6 +272,27 @@ fn symbol_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
within(128)(field, value)
}
/// A lowercase D-format UUID: 8-4-4-4-12 lowercase hex digits, any version.
fn uuid_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
let shaped = value.len() == 36
&& value.bytes().enumerate().all(|(at, byte)| match at {
8 | 13 | 18 | 23 => byte == b'-',
_ => matches!(byte, b'0'..=b'9' | b'a'..=b'f'),
});
if !shaped {
return Err(format_error(field, value));
}
Ok(())
}
/// A person's words: 1 to 4000 UTF-8 bytes and not blank (`str::trim` leaves something).
fn words_text(field: &str, value: &str) -> Result<(), PipelineRefusal> {
if value.trim().is_empty() {
return Err(format_error(field, value));
}
within(4000)(field, value)
}
bounded_text! {
/// Text of at most 200 UTF-8 bytes.
Short = 200, |field, value| within(200)(field, value);
@ -290,6 +311,11 @@ bounded_text! {
Symbol = 128, symbol_text;
/// Text of at most 4,000 UTF-8 bytes; longer narrative is cited by `DocRef`.
Para = 4000, |field, value| within(4000)(field, value);
/// A person's words, verbatim: 1 to 4,000 UTF-8 bytes, not blank (CultLib PR #72, `answer.note`).
#[schemars(extend("minLength" = 1))]
Words = 4000, words_text;
/// A client's retry token: a lowercase D-format UUID.
RequestId = 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 `.`.
@ -738,6 +764,11 @@ unit_enums! {
RunOperator { Operator, Mind }
RunTurn { PersonaTurn, SelfRun, Sleep }
MemorySort { Asked, Decided, Committed, Learned, Did, Idea, Rapport }
// The role the verified account holds, an attribution copied from Bifrost's state (CultLib PR #72,
// fork-card.md). It confers nothing: whether an answerer may decide is the decider list's, never this.
DeskAuthority { Operator, Steward, Member }
// Why a desk answer was refused and stored: binding spec 1.3 steps 4-7 and R1.
DeskRefusal { StaleFork, NotADecider, ForkClosed, ChoiceInvalid, BadRetraction, AlreadyRetracted }
}
/// The slots of a Mind's runs: one live run of hers per instance and slot. A
@ -838,6 +869,25 @@ value_types! {
structural_delta: StructuralDelta, landed_names: Vec<LandedName>[128], undone: Vec<Line>[32],
promises: Vec<Promise>[64],
}
/// One answer the fork desk's receiver passed to the Mind, refused ones included.
/// `answerer` and `authority` (an attribution, never a decision) are stamped by the
/// Mind from the verified caller and are absent from the request type; `answered_at`
/// is the client's claim kept as written, display only: order is `sequence`, the
/// receiver's order. `received_at` is the Mind's admission time (UTC) and
/// `claim_in_future` is set when `answered_at` is more than five minutes after it.
/// `request_id` is the client's retry token and `body_sha256` the digest of the
/// request it came with: the pair that makes a retry the same answer, never a new
/// one; the key is `(answerer, request_id)`. A record holds no card: a replayed
/// `stale_fork` is shown the current card. `sequence` is per question and is the
/// last part of the key (the answer id). `choice`, `note` and (for a retraction) the
/// target are kept as sent whether the request was counted or refused, so a refused
/// `choice_invalid` keeps a choice that is no option label; which answer counts now is
/// derived by the reader, never stored here.
pub struct PipelineDeskAnswer {
campaign: Slug, fork: Short, sequence: u32, options_hash: Sha256Hex, choice: Option<Short>, note: Option<Words>,
answered_at: Short, received_at: Short, claim_in_future: bool, request_id: RequestId, body_sha256: Sha256Hex,
answerer: Short, authority: DeskAuthority, outcome: DeskOutcome,
}
pub struct PipelineVerdict { campaign: Slug, cut_report: Short, pass: u32, range: CommitRange, claims: Vec<VerdictClaim>[64] }
pub struct PipelineFinding {
campaign: Slug, verdict: Short, label: Label, range: CommitRange, confidence: FindingConfidence,
@ -1124,6 +1174,48 @@ impl Bounded for ResolutionOutcome {
}
}
/// The one tagged outcome of a desk answer. A retraction is a variant, so a
/// retraction without a target and a target without a retraction cannot be
/// stored. `of` and `retracts` name desk answers by answer id (the record key).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum DeskOutcome {
/// The answer that counts for its fork when it was admitted.
Counted,
/// A later passing answer, kept visible; `of` is the counted answer it followed.
Revision { of: Short },
Retraction { retracts: Short },
/// A request the desk would not count, kept with what it was sent with. `retracts` is the
/// target a refused retraction named, as sent and unchecked (it may name no answer at all);
/// it is `None` on a refused answer, and it is never a citation.
Refused { code: DeskRefusal, retracts: Option<Short> },
}
impl DeskOutcome {
/// The variant's spelling, for headers and listings.
pub fn name(&self) -> &'static str {
match self {
Self::Counted => "Counted",
Self::Revision { .. } => "Revision",
Self::Retraction { .. } => "Retraction",
Self::Refused { .. } => "Refused",
}
}
}
impl Bounded for DeskOutcome {
fn validate(&self, field: &str) -> Result<(), PipelineRefusal> {
match self {
Self::Counted => Ok(()),
Self::Refused { retracts, .. } => retracts.as_ref().map_or(Ok(()), |target| target.validate(&format!("{field}.retracts"))),
Self::Revision { of: id } | Self::Retraction { retracts: id } => {
let field = format!("{field}.{}", if matches!(self, Self::Revision { .. }) { "of" } else { "retracts" });
id.validate(&field)?;
pipeline_id(&field, &id.0, PipelineKind::DeskAnswer).map(|_| ())
}
}
}
}
macro_rules! pipeline_kinds {
($($variant:ident($value:ident) => $document:ident, $name:literal, $type_id:tt, $schema:tt;)*) => {
$(
@ -1283,6 +1375,8 @@ pipeline_kinds! {
"epiphany.pipeline.reply.v2", "EpiphanyPipelineReplyDocument";
Thought(PipelineThought) => EpiphanyPipelineThoughtDocument, "thought",
"epiphany.pipeline.thought.v2", "EpiphanyPipelineThoughtDocument";
DeskAnswer(PipelineDeskAnswer) => EpiphanyPipelineDeskAnswerDocument, "desk_answer",
"epiphany.pipeline.desk_answer.v2", "EpiphanyPipelineDeskAnswerDocument";
}
/// Parses a full document id: the grammar read backwards. A key and an id are
@ -1393,6 +1487,8 @@ const _: () = assert!(64 + ":".len() + LONGEST_KIND_NAME + ":".len() + RESOLUTIO
fn local_max(kind: PipelineKind) -> usize {
match kind {
PipelineKind::Resolution => RESOLUTION_LOCAL_MAX,
// `<question label>.n<sequence>`: every question label is answerable.
PipelineKind::DeskAnswer => SUBJECT_LOCAL_MAX + SEQUENCE_PART_MAX,
_ => SUBJECT_LOCAL_MAX,
}
}
@ -1502,6 +1598,11 @@ pub fn pipeline_key(document: &PipelineDocument) -> Result<String, PipelineRefus
local(&key_field, document_kind, &[&format!("cut-{cut}"), &format!("h{}", value.attempt)])?,
)
}
D::DeskAnswer(value) => {
let label = parent_local("desk_answer.fork", &value.fork.0, &value.campaign.0, PipelineKind::Question)?;
let sequence = format!("n{}", value.sequence);
(campaign_field.as_str(), value.campaign.0.as_str(), local(&key_field, document_kind, &[label, &sequence])?)
}
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')?;
@ -1774,6 +1875,13 @@ mod tests {
nearest_permille: vec![310, 870], chosen: 0, projector_ms: 4200, continuer_ms: 9100,
prompt_version: "ghostlight=c43eb54; words=180".into(),
}), format!("{INSTANCE}:thought:t1793000000")),
(D::DeskAnswer(PipelineDeskAnswer {
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,
outcome: DeskOutcome::Revision { of: id("desk_answer", "Q1.n1") },
}), format!("{CAMPAIGN}:desk_answer:Q1.n1")),
]
}
@ -3908,11 +4016,11 @@ 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(), 20, "twenty kinds");
assert_eq!(PipelineKind::ALL.len(), 21, "twenty-one kinds");
assert_eq!(
variants.iter().map(tag).collect::<Vec<_>>(),
PipelineKind::ALL.iter().map(|kind| kind.name().to_owned()).collect::<Vec<_>>(),
"the document schema lists exactly the twenty kinds, by name"
"the document schema lists exactly the twenty-one kinds, by name"
);
let refusal_schema = serde_json::to_value(schemars::schema_for!(PipelineRefusal))?;
@ -4436,4 +4544,40 @@ mod tests {
);
Ok(())
}
fn desk_answer_sample() -> PipelineDeskAnswer {
let PipelineDocument::DeskAnswer(answer) = samples().pop().unwrap().0 else { unreachable!() };
answer
}
#[test]
fn a_desk_answer_is_keyed_under_its_question_and_sequence() {
let mut answer = desk_answer_sample();
let key = |answer: &PipelineDeskAnswer| pipeline_key(&PipelineDocument::DeskAnswer(answer.clone()));
assert_eq!(key(&answer), Ok(format!("{CAMPAIGN}:desk_answer:Q1.n1")));
// Every 64-byte question label is answerable at the largest sequence.
let label = "q".repeat(64);
answer.fork = id("question", &label);
answer.sequence = u32::MAX;
answer.outcome = DeskOutcome::Counted;
assert_eq!(key(&answer), Ok(format!("{CAMPAIGN}:desk_answer:{label}.n4294967295")));
// The fork must be a question of the same campaign.
answer.fork = Short(format!("other:question:{label}"));
assert!(matches!(key(&answer), Err(PipelineRefusal::InvalidFormat { .. })));
answer.fork = id("ruling", "Q1");
assert!(matches!(key(&answer), Err(PipelineRefusal::InvalidFormat { .. })));
}
#[test]
fn a_desk_outcome_names_desk_answers_by_id() {
let mut answer = desk_answer_sample();
for bad in [id("question", "Q1"), Short("n1".into())] {
answer.outcome = DeskOutcome::Retraction { retracts: bad.clone() };
assert!(matches!(PipelineDocument::DeskAnswer(answer.clone()).validate(), Err(PipelineRefusal::InvalidFormat { .. })), "{bad:?}");
answer.outcome = DeskOutcome::Revision { of: bad.clone() };
assert!(matches!(PipelineDocument::DeskAnswer(answer.clone()).validate(), Err(PipelineRefusal::InvalidFormat { .. })), "{bad:?}");
}
answer.outcome = DeskOutcome::Retraction { retracts: id("desk_answer", "Q1.n2") };
assert_eq!(PipelineDocument::DeskAnswer(answer).validate(), Ok(()));
}
}

View file

@ -49,7 +49,7 @@ pub fn check(
}
text.push_str(&format!("For {id}:\n"));
for (_, view) in &found {
text.push_str(&render::header(&PipelineDocumentSummary::of(view)));
text.push_str(&render::header(&PipelineDocumentSummary::of(view, &|_| None)));
text.push('\n');
}
}

View file

@ -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,
hand_off, run, pulse, memory, research, idea, reply, thought, desk_answer,
);
/// The schema named, or the names when none is: the exit code is 2 for a name

View file

@ -1144,12 +1144,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(), 21, "{names:?}");
assert_eq!(names.len(), 22, "{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]), ("reply", "thought", "selection"));
assert_eq!((names[18], names[19], names[20], names[21]), ("reply", "thought", "desk_answer", "selection"));
for kind in PipelineKind::ALL {
assert!(names.contains(&kind.name()), "{}", kind.name());
}

View file

@ -28,6 +28,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_path_to_error = "0.1"
sha2 = "0.10"
unicode-normalization = "0.1"
[dev-dependencies]
tempfile = "3"

View file

@ -51,6 +51,26 @@ use crate::receipt::{self, CommitOutcome, IMPORT_EXTERNAL_MAX, ImportSource, Pip
use crate::refusal::{MindRefusal, RunId};
use crate::store::MindStore;
/// The door a batch enters by. A desk answer is decided by `Mind::answer_desk`
/// before its document exists, from a stamp the caller cannot forge, so only the
/// desk's own call passes `Desk`; `admit`, `admit_prepared`, imports and
/// derivations pass `Admit` and may not write one (ruling
/// desk-second-peer-checked-door).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Door {
Admit,
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 most envelopes one batch may carry.
pub const BATCH_MAX: usize = 64;
@ -77,7 +97,7 @@ pub enum PipelineAdmissionOutcome {
/// Keys a document through the leaf, then encodes it. A key the leaf cannot
/// compose is the document's fault and is refused as a document; only the
/// cache's encode fault is the organ's.
fn prepare(document: &PipelineDocument, cache: &CultCache) -> Result<CultCacheEnvelope, MindRefusal> {
pub(crate) fn prepare(document: &PipelineDocument, cache: &CultCache) -> Result<CultCacheEnvelope, MindRefusal> {
pipeline_key(document).map_err(MindRefusal::Document)?;
document.prepare(cache).map_err(unavailable)
}
@ -108,14 +128,15 @@ impl<S: MindStore> Mind<S> {
envelopes: Vec<CultCacheEnvelope>,
now: DateTime<Utc>,
) -> PipelineAdmissionOutcome {
match self.admit_steps(instance, provenance, envelopes, now) {
match self.admit_steps(Door::Admit, instance, provenance, envelopes, now) {
Ok(outcome) => outcome,
Err(refusal) => PipelineAdmissionOutcome::Refused(refusal),
}
}
fn admit_steps(
pub(crate) fn admit_steps(
&mut self,
door: Door,
instance: &Slug,
provenance: PipelineProvenance,
envelopes: Vec<CultCacheEnvelope>,
@ -139,6 +160,7 @@ impl<S: MindStore> Mind<S> {
for envelope in envelopes {
docs.push(stage(envelope, &mind)?)?;
}
refuse_desk_answers_outside_desk(door, &docs)?;
// A6
let carries_instance = docs.batch.iter().filter(|staged| staged.kind == PipelineKind::Instance).count() == 1;
if self.is_empty() && !carries_instance {
@ -162,6 +184,7 @@ impl<S: MindStore> Mind<S> {
docs.push(stage(envelope, &mind)?)?;
}
}
refuse_desk_answers_outside_desk(door, &docs)?;
for staged in &docs.batch[originals..] {
resolve(&docs, staged, &mind, external, &mut strong)?;
}
@ -602,6 +625,8 @@ fn check(docs: &Docs<'_>, staged: &Staged, mind: &Slug, import: Option<&ImportSo
}
unique_labels("question.options", question.options.iter().map(|option| option.label.0.as_str()))
}
// Decided in `answer_desk` before the document exists; no other door can stage one.
D::DeskAnswer(_) => Ok(()),
D::Ruling(ruling) => {
if ruling.operator_quote.is_some() && ruling.authority != RulingAuthority::Operator {
return Err(MindRefusal::QuoteWithoutOperator { ruling: key.into() });

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,7 @@
use cultcache_rs::CultCacheEnvelope;
use eureka_pipeline::{
Label, OrgRepo, PipelineCampaign, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, PipelineRun, PipelineStewardship, ResolutionOutcome,
DeskOutcome, Label, OrgRepo, PipelineCampaign, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, PipelineRun, PipelineStewardship, ResolutionOutcome,
ResearchSource, RunOperator, Short, Slug,
};
use schemars::JsonSchema;
@ -382,7 +382,8 @@ pub(crate) fn line_of(document: &PipelineDocument) -> Option<Lineage<'_>> {
| D::Run(_)
| D::Pulse(_)
| D::Reply(_)
| D::Thought(_) => None,
| D::Thought(_)
| D::DeskAnswer(_) => None,
}
}
@ -434,10 +435,12 @@ pub enum CitationRole {
Run,
Reads,
DayThrough,
Fork,
Answer,
}
impl CitationRole {
pub const ALL: [CitationRole; 24] = [
pub const ALL: [CitationRole; 26] = [
Self::RaisedIn,
Self::Answers,
Self::Rulings,
@ -462,6 +465,8 @@ impl CitationRole {
Self::Run,
Self::Reads,
Self::DayThrough,
Self::Fork,
Self::Answer,
];
/// The wire spelling, equal to the referring field's name.
@ -491,6 +496,8 @@ impl CitationRole {
Self::Run => "run",
Self::Reads => "reads",
Self::DayThrough => "day_through",
Self::Fork => "fork",
Self::Answer => "answer",
}
}
@ -517,6 +524,7 @@ impl CitationRole {
Self::RestsOn | Self::Toward => K::Idea,
Self::Run => K::Reply,
Self::Reads => K::Thought,
Self::Fork | Self::Answer => K::DeskAnswer,
}
}
@ -526,7 +534,7 @@ impl CitationRole {
pub fn target_kinds(self) -> Vec<PipelineKind> {
use PipelineKind as K;
match self {
Self::Answers | Self::Questions | Self::Forks => vec![K::Question],
Self::Answers | Self::Questions | Self::Forks | Self::Fork => vec![K::Question],
Self::Rulings => vec![K::Ruling],
Self::CutSpec => vec![K::CutSpec],
Self::CutReport => vec![K::CutReport],
@ -535,6 +543,7 @@ impl CitationRole {
Self::Claims => vec![K::CutSpec, K::Finding, K::FollowUp],
Self::Consolidates | Self::DayThrough => vec![K::Memory],
Self::Run => vec![K::Run],
Self::Answer => vec![K::DeskAnswer],
Self::RaisedIn
| Self::Source
| Self::Subject
@ -636,6 +645,13 @@ pub(crate) fn citations(document: &PipelineDocument) -> Vec<(CitationRole, Pipel
edges.extend(idea.rests_on.iter().map(|to| (R::RestsOn, to.clone())));
edges.extend(idea.toward.iter().map(|to| (R::Toward, to.clone())));
}
D::DeskAnswer(answer) => {
edges.push((R::Fork, cited(K::Question, &answer.fork)));
match &answer.outcome {
DeskOutcome::Revision { of: id } | DeskOutcome::Retraction { retracts: id } => edges.push((R::Answer, cited(K::DeskAnswer, id))),
DeskOutcome::Counted | DeskOutcome::Refused { .. } => {}
}
}
D::Campaign(_) | D::Target(_) | D::Instance(_) | D::Stewardship(_) | D::Pulse(_) => {}
}
edges

View file

@ -102,7 +102,8 @@ pub fn index_text(document: &PipelineDocument) -> Option<String> {
lines.push(&idea.idea.0);
lines.push(&idea.tradeoffs.0);
}
D::Instance(_) | D::Stewardship(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) => {}
// A desk answer is a person's words, not indexed.
D::Instance(_) | D::Stewardship(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::DeskAnswer(_) => {}
}
lines.retain(|line| !line.is_empty());
if lines.is_empty() {

View file

@ -27,6 +27,7 @@
//! `now` is passed in.
pub mod admission;
mod desk;
mod docs;
mod image;
pub mod envelope;
@ -52,6 +53,7 @@ mod read_tests;
#[cfg(test)]
mod move_tests;
pub use desk::{DeskAnswerOutcome, DeskAnswerRequest, DeskBody, options_hash};
pub use docs::CitationRole;
pub use index::{INDEX_TEXT_MAX_BYTES, INDEX_TEXT_VERSION, IndexEntry, index_text};
pub use admission::{BATCH_MAX, PipelineAdmissionBatch, PipelineAdmissionOutcome};

View file

@ -18,7 +18,7 @@ use eureka_pipeline::{
Line, Para, Short, Slug, StructuralDelta, pipeline_key,
};
use crate::docs::{citations, kind_of_type};
use crate::docs::{Docs, citations, kind_of_type};
use crate::mind::{Mind, schema_cache};
use crate::persona::HuginnPersonaEntry;
use crate::query::{AdmissionFacts, PipelineDocumentView, PipelineSelectionPage, PipelineStatus};
@ -168,7 +168,7 @@ pub fn oracle_page<S: MindStore>(mind: &Mind<S>, selection: &Selection, as_of: u
let rows: Vec<SelectionRow> = visible.iter().map(|doc| row_of(&visible, doc)).collect();
let rows: Vec<&SelectionRow> = rows.iter().collect();
let evaluation = select(&Vocabulary, &rows, &selection, as_of, mind.cursor_key())?;
Mind::<S>::page(&selection, evaluation, as_of, None)
Mind::<S>::page(&selection, evaluation, &Docs::at(mind.image()?, as_of), as_of, None)
}
/// The view of one document at `as_of`, derived from the store alone.

View file

@ -31,13 +31,14 @@ use cultnet_rs::{
Cursor, EdgeAnchor, EdgeMatch, Evaluation, LIMIT_MAX, LIMIT_MIN, PROJECTION_DOCUMENT, Selection, select, validate,
};
use eureka_pipeline::{
ClaimOutcome, CommitRange, Date, FindingConfidence, FindingOrigin, FindingSeverity, Label, Line, MemoryOrigin, MemorySort, OrgRepo,
ClaimOutcome, CommitRange, Date, DeskAuthority, FindingConfidence, FindingOrigin, FindingSeverity, Label, Line, MemoryOrigin, MemorySort, OrgRepo,
PipelineCampaign, PipelineDocument, PipelineKind, PipelineRef, PipelineResolution, ResolutionOutcome, RulingAuthority, RunOperator, RunTurn, Sha,
Short, Slug, Title, pipeline_key,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::desk::counted_answer;
use crate::docs::{CitationRole, Docs, kind_of_type};
use crate::image::{Image, status_of};
use crate::mind::{Mind, schema_cache};
@ -161,10 +162,15 @@ pub enum PipelineFacts {
Memory { label: Label, sort: MemorySort, about: Vec<Short>, origin: MemoryOrigin, salience_permille: u16 },
Research { pass: Label, label: Label, claim: Line, inferred: bool, observed_on: Date },
Idea { pass: Label, label: Label, title: Title, proposed_on: Date },
/// `outcome` is the stored, write-time outcome's variant name. `counts` is whether this answer is
/// 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 },
}
impl PipelineFacts {
fn of(document: &PipelineDocument) -> Self {
/// `counted_of` answers, for a fork, the key of the answer that counts at the read's snapshot.
fn of(id: &PipelineRef, document: &PipelineDocument, counted_of: &dyn Fn(&str) -> Option<String>) -> Self {
use PipelineDocument as D;
match document {
D::Campaign(value) => Self::Campaign { title: value.title.clone(), repos: value.repos.clone() },
@ -293,13 +299,22 @@ impl PipelineFacts {
title: value.title.clone(),
proposed_on: value.proposed_on.clone(),
},
D::DeskAnswer(value) => Self::DeskAnswer {
fork: value.fork.clone(),
sequence: value.sequence,
outcome: Short(value.outcome.name().into()),
counts: counted_of(&value.fork.0).as_deref() == Some(id.id.0.as_str()),
authority: value.authority,
},
}
}
}
impl PipelineDocumentSummary {
/// The header of a view: what a `headers` projection answers for it.
pub fn of(view: &PipelineDocumentView) -> Self {
/// `counted_of` names, for a desk fork, the answer that counts at the snapshot the view was read
/// at; a caller that summarises no desk answers passes `&|_| None`.
pub fn of(view: &PipelineDocumentView, counted_of: &dyn Fn(&str) -> Option<String>) -> Self {
Self {
id: view.id.clone(),
admission: view.admission.clone(),
@ -309,7 +324,7 @@ impl PipelineDocumentSummary {
PipelineStatusSummary::Resolved { resolution: resolution.clone(), outcome: record.outcome.clone() }
}
},
facts: PipelineFacts::of(&view.document),
facts: PipelineFacts::of(&view.id, &view.document, counted_of),
}
}
}
@ -639,7 +654,7 @@ impl<S: MindStore> Mind<S> {
let ids = reader.universe(selection);
let rows = reader.rows(&ids);
let evaluation = select(&Vocabulary, &rows, selection, as_of, self.cursor_key())?;
Self::page(selection, evaluation, as_of, None)
Self::page(selection, evaluation, &reader.docs, as_of, None)
}
/// Every root this mind holds a document under, with its newest admission and, for a root that is an in-force
@ -807,7 +822,7 @@ impl<S: MindStore> Mind<S> {
kept.sort_by_key(|(at, _)| *at);
evaluation.edges = kept.into_iter().map(|(_, edge)| edge).collect();
evaluation.matched = u32::try_from(members.ids.len()).unwrap_or(u32::MAX);
Ok(Ranked { page: Self::page(&selection, evaluation, as_of, Some(row_scores))?, dropped })
Ok(Ranked { page: Self::page(&selection, evaluation, &reader.docs, as_of, Some(row_scores))?, dropped })
}
/// The projection of an evaluation: a header or a whole view per row, and
@ -815,13 +830,14 @@ impl<S: MindStore> Mind<S> {
pub(crate) fn page(
selection: &Selection,
evaluation: Evaluation<&SelectionRow>,
docs: &Docs<'_>,
as_of: u64,
scores: Option<Vec<f32>>,
) -> Result<PipelineSelectionPage, MindRefusal> {
let items = if selection.projection == PROJECTION_DOCUMENT {
PipelinePageItems::Documents(evaluation.rows.iter().map(|row| row.view.clone()).collect())
} else {
PipelinePageItems::Headers(evaluation.rows.iter().map(|row| PipelineDocumentSummary::of(&row.view)).collect())
PipelinePageItems::Headers(evaluation.rows.iter().map(|row| PipelineDocumentSummary::of(&row.view, &|fork| counted_answer(docs, fork))).collect())
};
let edges = if selection.has_hop() {
let mut typed = Vec::with_capacity(evaluation.edges.len());
@ -1726,7 +1742,7 @@ mod tests {
let page = mind.query(&one).unwrap();
let expected = view(&mind, K::Question, &q1);
assert_eq!(page.edges, None, "no hop, no edges");
assert_eq!(headers(&page), [PipelineDocumentSummary::of(&expected)]);
assert_eq!(headers(&page), [PipelineDocumentSummary::of(&expected, &|_| None)]);
assert_eq!(headers(&page)[0].facts, PipelineFacts::Question {
label: l("Q1"),
title: "Who owns the state?".into(),
@ -1748,7 +1764,7 @@ mod tests {
thought.nearest_permille = vec![300, 450];
thought.chosen = 1;
thought.text = "B.".into();
assert_eq!(PipelineFacts::of(&D::Thought(thought)), PipelineFacts::Thought { label: "t1".into(), chosen: 1, nearest_permille: 450 });
assert_eq!(PipelineFacts::of(&PipelineRef { kind: K::Thought, id: id("thought", "t1").as_str().into() }, &D::Thought(thought), &|_| None), PipelineFacts::Thought { label: "t1".into(), chosen: 1, nearest_permille: 450 });
}
#[test]
@ -1812,6 +1828,21 @@ mod tests {
way_document.toward = vec![r(K::Question, &q1)];
committed(admit(&mut mind, vec![D::Research(note_document), D::Idea(way_document)]));
// 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 pick = |n: u32| crate::desk::DeskAnswerRequest {
fork: s(&q2),
options_hash: crate::desk::options_hash(&shown),
body: crate::desk::DeskBody::Answer { choice: Some(s("A")), note: None },
answered_at: s("2026-11-10T12:00:00Z"),
request_id: eureka_pipeline::RequestId(format!("00000000-0000-4000-8000-{n:012x}")),
};
let (answer_1, answer_2) = (id("desk_answer", "Q2.n1"), id("desk_answer", "Q2.n2"));
for n in 1..=2 {
assert!(matches!(mind.answer_desk(stamp.clone(), pick(n), now()), crate::desk::DeskAnswerOutcome::Stored { .. }));
}
let (n1, f1_closed, f2_closed) = (
id("resolution", "question.Q1.n1"),
id("resolution", &format!("finding.{}.n1", f1.rsplit(':').next().unwrap())),
@ -1857,8 +1888,11 @@ mod tests {
(CitationRole::Sources, &note, &f1),
(CitationRole::RestsOn, &way, &note),
(CitationRole::Toward, &way, &q1),
(CitationRole::Fork, &answer_1, &q2),
(CitationRole::Fork, &answer_2, &q2),
(CitationRole::Answer, &answer_2, &answer_1),
];
assert_eq!(CitationRole::ALL.len(), 24);
assert_eq!(CitationRole::ALL.len(), 26);
for role in CitationRole::ALL {
let selection =
Selection { cited: Some(Incoming { role: role.name().into(), exists: true }), ..Selection::default() };

View file

@ -29,7 +29,9 @@ pub const RECEIPT_SCHEMA_VERSION: &str = "huginn.mind_commit_receipt.v2";
/// Which faculty a batch was admitted as. Attribution, not authority
/// (ruling 18): no admission rule trusts it, and Cut 9's views read it back
/// from the receipt.
/// from the receipt. `Member` and `Steward` name a desk answerer who is neither the
/// operator nor a faculty; they confer no admission right anywhere (a test admits a
/// batch under each and checks the outcomes match).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum Faculty {
SelfFaculty,
@ -40,6 +42,8 @@ pub enum Faculty {
Life,
Eyes,
Operator,
Member,
Steward,
}
/// Who admitted a batch: the faculty, the agent, its session and the tool.

View file

@ -44,6 +44,12 @@ pub enum MindRefusal {
InvalidOptions { question: String },
InvalidChoice { ruling: String, choice: String },
QuoteWithoutOperator { ruling: String },
/// A `desk_answer` reached a door other than the desk's (an admit, an import or a derivation): `id` is its key.
DeskAnswerOutsideDesk { id: String },
/// A desk request the Mind cannot read as an answer: `field` names the part (the fork, `answered_at`, an answer carrying neither choice nor note, a malformed answer id).
DeskRequestInvalid { field: String },
/// The desk's `request_id_conflict` (CultLib PR #72): this `(answerer, request_id)` already bound another body. Nothing is stored.
DeskRequestIdConflict { request_id: String },
/// `campaign` names the refused document: a campaign, or a target revision that empties its predecessor's repos.
EmptyRepos { campaign: String },
RepoNotInCampaign { repo: String },

View file

@ -63,7 +63,7 @@ pub(crate) enum Alias {
Pass,
}
const FACULTIES: [&str; 7] = ["SelfFaculty", "Imagination", "Hands", "Soul", "Life", "Eyes", "Operator"];
const FACULTIES: [&str; 9] = ["SelfFaculty", "Imagination", "Hands", "Soul", "Life", "Eyes", "Operator", "Member", "Steward"];
const OUTCOMES: [&str; 8] = ["Superseded", "Answered", "Fixed", "Deferred", "Recorded", "Withdrawn", "Slept", "Moved"];
const BOOLEANS: [&str; 2] = ["true", "false"];
const MEMORY_ORIGINS: [&str; 5] = ["Room", "Direct", "Wake", "SelfRun", "Sleep"];
@ -246,7 +246,7 @@ impl SelectionRow {
D::FollowUp(follow_up) => vec![follow_up.repo.identity()],
D::Stewardship(stewardship) => vec![stewardship.repo.identity()],
D::HandOff(hand_off) => vec![hand_off.repo.identity()],
D::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::Target(_) | D::Question(_) | D::Ruling(_) | D::Verdict(_) | D::Finding(_) | D::Instance(_) | D::Resolution(_) | D::Run(_) | D::Pulse(_) | D::Reply(_) | D::Thought(_) | D::Memory(_) | D::Research(_) | D::Idea(_) | D::DeskAnswer(_) => {
Vec::new()
}
},
@ -435,6 +435,20 @@ mod tests {
names
}
/// The `faculty` alias values are exactly Faculty's serde names; the exhaustive match makes
/// a new variant a compile error here until FACULTIES names it.
#[test]
fn the_faculty_alias_values_are_the_faculty_serde_names() {
use crate::receipt::Faculty;
let named = |faculty: Faculty| match faculty {
Faculty::SelfFaculty | Faculty::Imagination | Faculty::Hands | Faculty::Soul | Faculty::Life | Faculty::Eyes | Faculty::Operator | Faculty::Member | Faculty::Steward => {
serde_json::to_value(faculty).unwrap().as_str().unwrap().to_string()
}
};
let all = [Faculty::SelfFaculty, Faculty::Imagination, Faculty::Hands, Faculty::Soul, Faculty::Life, Faculty::Eyes, Faculty::Operator, Faculty::Member, Faculty::Steward];
assert_eq!(sorted(all.into_iter().map(named).collect()), strings(&FACULTIES));
}
fn strings(names: &[&str]) -> Vec<String> {
sorted(names.iter().map(|name| name.to_string()).collect())
}
@ -445,7 +459,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]); 20] = [
let table: [(K, &[&str]); 21] = [
(K::Campaign, &["repo"]),
(K::Target, &[]),
(K::Question, &[]),
@ -466,6 +480,7 @@ mod tests {
(K::Idea, &["pass"]),
(K::Reply, &[]),
(K::Thought, &[]),
(K::DeskAnswer, &[]),
];
for (kind, extra) in table {
let want = [&["root", "in_force", "faculty"][..], extra].concat();
@ -478,10 +493,10 @@ mod tests {
/// Each role is declared by the one kind whose field carries it, and its
/// targets are the kinds that field is typed to, "any" spelled as all
/// twenty.
/// twenty-one.
#[test]
fn each_kind_declares_the_roles_it_carries_and_their_targets() {
let table: [(K, &[&str]); 20] = [
let table: [(K, &[&str]); 21] = [
(K::Campaign, &[]),
(K::Target, &[]),
(K::Question, &["raised_in"]),
@ -502,13 +517,14 @@ mod tests {
(K::Idea, &["rests_on", "toward"]),
(K::Reply, &["run"]),
(K::Thought, &["reads"]),
(K::DeskAnswer, &["fork", "answer"]),
];
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(), 20);
assert_eq!(Vocabulary.target_leaves("subject").len(), 21);
assert_eq!(
sorted(Vocabulary.target_leaves("claims")),
sorted([K::CutSpec, K::Finding, K::FollowUp].iter().map(|kind| kind.type_id().to_string()).collect())

View file

@ -0,0 +1,343 @@
[
{
"name": "heimdall_A_five_members",
"heimdall": "A",
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": [
"gamecult-chat:question:file-zone-audience"
],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48"
},
{
"name": "heimdall_B_recommendation_changed",
"heimdall": "B",
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "yes",
"depends": [
"gamecult-chat:question:file-zone-audience"
],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"yes\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A22796573222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc"
},
{
"name": "heimdall_C_option_order_changed",
"heimdall": "C",
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "no",
"text": "Keep it local."
},
{
"id": "yes",
"text": "Share it."
}
],
"recommendation": "no",
"depends": [
"gamecult-chat:question:file-zone-audience"
],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"no\",\"text\":\"Keep it local.\"},{\"id\":\"yes\",\"text\":\"Share it.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D2C7B226964223A22796573222C2274657874223A2253686172652069742E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "ac67b41539f869f6203ac0908819df046e4328b27795743a919bd4ab390596b5"
},
{
"name": "heimdall_D_nfc_escapes_astral",
"heimdall": "D",
"title": "Café “desk”",
"context": "Line one\nLine two — 😀",
"options": [
{
"id": "a",
"text": "x"
},
{
"id": "b",
"text": "y"
}
],
"recommendation": "a",
"depends": [],
"canonical": "{\"context\":\"Line one\\nLine two — 😀\",\"depends\":[],\"options\":[{\"id\":\"a\",\"text\":\"x\"},{\"id\":\"b\",\"text\":\"y\"}],\"recommendation\":\"a\",\"title\":\"Café “desk”\"}",
"canonicalHex": "7B22636F6E74657874223A224C696E65206F6E655C6E4C696E652074776F20E2809420F09F9880222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A2261222C2274657874223A2278227D2C7B226964223A2262222C2274657874223A2279227D5D2C227265636F6D6D656E646174696F6E223A2261222C227469746C65223A22436166C3A920E2809C6465736BE2809D227D",
"sha256": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272"
},
{
"name": "heimdall_E_decomposed_equals_D",
"heimdall": "E",
"title": "Café “desk”",
"context": "Line one\nLine two — 😀",
"options": [
{
"id": "a",
"text": "x"
},
{
"id": "b",
"text": "y"
}
],
"recommendation": "a",
"depends": [],
"canonical": "{\"context\":\"Line one\\nLine two — 😀\",\"depends\":[],\"options\":[{\"id\":\"a\",\"text\":\"x\"},{\"id\":\"b\",\"text\":\"y\"}],\"recommendation\":\"a\",\"title\":\"Café “desk”\"}",
"canonicalHex": "7B22636F6E74657874223A224C696E65206F6E655C6E4C696E652074776F20E2809420F09F9880222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A2261222C2274657874223A2278227D2C7B226964223A2262222C2274657874223A2279227D5D2C227265636F6D6D656E646174696F6E223A2261222C227469746C65223A22436166C3A920E2809C6465736BE2809D227D",
"sha256": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272"
},
{
"name": "heimdall_F_option_id_changed",
"heimdall": "F",
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "share",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": [
"gamecult-chat:question:file-zone-audience"
],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"share\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A227368617265222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "fb53aa3a9f7e74029f755d774064eedd240ab90d8fc64723f97c31b3d2846d40"
},
{
"name": "heimdall_G_depends_emptied",
"heimdall": "G",
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": [],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "f856edce121db4121b5f1af400b1c471ebbfa6579a042aca9a34ae31c15f5c86"
},
{
"name": "option_text_changed",
"heimdall": null,
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "yes",
"text": "Share it!"
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": [
"gamecult-chat:question:file-zone-audience"
],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it!\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A22536861726520697421227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "9ce0d2770497fdd2d5338e66f9f1ae1741d87301db4c793407c25762929392b6"
},
{
"name": "title_changed",
"heimdall": null,
"title": "Slot mark consent?",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": [
"gamecult-chat:question:file-zone-audience"
],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent?\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E743F227D",
"sha256": "30fe0c5bc88bd21d64592a495316c30e80fcee6cacf5c63fd9a818ca5cf87d0f"
},
{
"name": "context_changed",
"heimdall": null,
"title": "Slot mark consent",
"context": "Should slot marks be shared?",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": [
"gamecult-chat:question:file-zone-audience"
],
"canonical": "{\"context\":\"Should slot marks be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C6420736C6F74206D61726B73206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "716de65096e34fd89cc92848b667d90f5c27eeb66929da3d340cabb80155e173"
},
{
"name": "depends_order_changed",
"heimdall": null,
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": [
"gamecult-chat:question:file-zone-audience",
"x:question:y"
],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\",\"x:question:y\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365222C22783A7175657374696F6E3A79225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "fc3f651c6f498385256fc1232d5db31ece9dcc542e391f97245d27bc9e385b3d"
},
{
"name": "depends_order_changed_reversed",
"heimdall": null,
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": [
"x:question:y",
"gamecult-chat:question:file-zone-audience"
],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"x:question:y\",\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B22783A7175657374696F6E3A79222C2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "b22642e30f8d68839e3a0df5bc23b83ff50a839fcecf0366a65fa6e3cf873796"
},
{
"name": "hidden_members_do_not_move_it",
"heimdall": null,
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": [
"gamecult-chat:question:file-zone-audience"
],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48"
},
{
"name": "control_characters_and_markup_are_escaped_minimally",
"heimdall": null,
"title": "q \" b \\ t \t n \n bell \u0007 del ",
"context": "</script> 
 &",
"options": [
{
"id": "yes",
"text": "Share it."
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": [
"gamecult-chat:question:file-zone-audience"
],
"canonical": "{\"context\":\"</script> 
 &\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"q \\\" b \\\\ t \\t n \\n bell \\u0007 del \"}",
"canonicalHex": "7B22636F6E74657874223A223C2F7363726970743E20E280A82026222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A2271205C222062205C5C2074205C74206E205C6E2062656C6C205C75303030372064656C207F227D",
"sha256": "f2d5a09513ce728129f6ca13f7123aa75b2bb77541b1de8946f39239634b40fe"
},
{
"name": "astral_option_id_and_text",
"heimdall": null,
"title": "Slot mark consent",
"context": "Should a slot mark be shared?",
"options": [
{
"id": "😀",
"text": "𝄞"
},
{
"id": "no",
"text": "Keep it local."
}
],
"recommendation": "no",
"depends": [
"gamecult-chat:question:file-zone-audience"
],
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"😀\",\"text\":\"𝄞\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22F09F9880222C2274657874223A22F09D849E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
"sha256": "836fbfce16127132bae100aa773d328a85ad08d241ae9bf47ea7f84766762cac"
}
]

View file

@ -0,0 +1,210 @@
{
"$defs": {
"DeskAuthority": {
"enum": [
"Operator",
"Steward",
"Member"
],
"type": "string"
},
"DeskOutcome": {
"description": "The one tagged outcome of a desk answer. A retraction is a variant, so a\nretraction without a target and a target without a retraction cannot be\nstored. `of` and `retracts` name desk answers by answer id (the record key).",
"oneOf": [
{
"const": "Counted",
"description": "The answer that counts for its fork when it was admitted.",
"type": "string"
},
{
"additionalProperties": false,
"description": "A later passing answer, kept visible; `of` is the counted answer it followed.",
"properties": {
"Revision": {
"properties": {
"of": {
"$ref": "#/$defs/Short"
}
},
"required": [
"of"
],
"type": "object"
}
},
"required": [
"Revision"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"Retraction": {
"properties": {
"retracts": {
"$ref": "#/$defs/Short"
}
},
"required": [
"retracts"
],
"type": "object"
}
},
"required": [
"Retraction"
],
"type": "object"
},
{
"additionalProperties": false,
"description": "A request the desk would not count, kept with what it was sent with. `retracts` is the\ntarget a refused retraction named, as sent and unchecked (it may name no answer at all);\nit is `None` on a refused answer, and it is never a citation.",
"properties": {
"Refused": {
"properties": {
"code": {
"$ref": "#/$defs/DeskRefusal"
},
"retracts": {
"anyOf": [
{
"$ref": "#/$defs/Short"
},
{
"type": "null"
}
]
}
},
"required": [
"code"
],
"type": "object"
}
},
"required": [
"Refused"
],
"type": "object"
}
]
},
"DeskRefusal": {
"enum": [
"StaleFork",
"NotADecider",
"ForkClosed",
"ChoiceInvalid",
"BadRetraction",
"AlreadyRetracted"
],
"type": "string"
},
"RequestId": {
"description": "A client's retry token: a lowercase D-format UUID.",
"maxLength": 36,
"type": "string"
},
"Sha256Hex": {
"description": "A SHA-256 digest: exactly 64 lowercase hex characters.",
"maxLength": 64,
"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"
},
"Words": {
"description": "A person's words, verbatim: 1 to 4,000 UTF-8 bytes, not blank (CultLib PR #72, `answer.note`).",
"maxLength": 4000,
"minLength": 1,
"type": "string"
}
},
"$id": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.desk_answer.v2.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"description": "One answer the fork desk's receiver passed to the Mind, refused ones included.\n`answerer` and `authority` (an attribution, never a decision) are stamped by the\nMind from the verified caller and are absent from the request type; `answered_at`\nis the client's claim kept as written, display only: order is `sequence`, the\nreceiver's order. `received_at` is the Mind's admission time (UTC) and\n`claim_in_future` is set when `answered_at` is more than five minutes after it.\n`request_id` is the client's retry token and `body_sha256` the digest of the\nrequest it came with: the pair that makes a retry the same answer, never a new\none; the key is `(answerer, request_id)`. A record holds no card: a replayed\n`stale_fork` is shown the current card. `sequence` is per question and is the\nlast part of the key (the answer id). `choice`, `note` and (for a retraction) the\ntarget are kept as sent whether the request was counted or refused, so a refused\n`choice_invalid` keeps a choice that is no option label; which answer counts now is\nderived by the reader, never stored here.",
"properties": {
"answered_at": {
"$ref": "#/$defs/Short"
},
"answerer": {
"$ref": "#/$defs/Short"
},
"authority": {
"$ref": "#/$defs/DeskAuthority"
},
"body_sha256": {
"$ref": "#/$defs/Sha256Hex"
},
"campaign": {
"$ref": "#/$defs/Slug"
},
"choice": {
"anyOf": [
{
"$ref": "#/$defs/Short"
},
{
"type": "null"
}
]
},
"claim_in_future": {
"type": "boolean"
},
"fork": {
"$ref": "#/$defs/Short"
},
"note": {
"anyOf": [
{
"$ref": "#/$defs/Words"
},
{
"type": "null"
}
]
},
"options_hash": {
"$ref": "#/$defs/Sha256Hex"
},
"outcome": {
"$ref": "#/$defs/DeskOutcome"
},
"received_at": {
"$ref": "#/$defs/Short"
},
"request_id": {
"$ref": "#/$defs/RequestId"
},
"sequence": {
"format": "uint32",
"minimum": 0,
"type": "integer"
}
},
"required": [
"campaign",
"fork",
"sequence",
"options_hash",
"answered_at",
"received_at",
"claim_in_future",
"request_id",
"body_sha256",
"answerer",
"authority",
"outcome"
],
"title": "PipelineDeskAnswer",
"type": "object"
}

View file

@ -177,7 +177,8 @@
"Research",
"Idea",
"Reply",
"Thought"
"Thought",
"DeskAnswer"
],
"type": "string"
},

View file

@ -181,7 +181,8 @@
"Research",
"Idea",
"Reply",
"Thought"
"Thought",
"DeskAnswer"
],
"type": "string"
},

View file

@ -36,7 +36,8 @@
"Research",
"Idea",
"Reply",
"Thought"
"Thought",
"DeskAnswer"
],
"type": "string"
},

View file

@ -153,7 +153,8 @@
"Research",
"Idea",
"Reply",
"Thought"
"Thought",
"DeskAnswer"
],
"type": "string"
},

View file

@ -41,7 +41,8 @@
"Research",
"Idea",
"Reply",
"Thought"
"Thought",
"DeskAnswer"
],
"type": "string"
},

View file

@ -31,7 +31,8 @@
"Research",
"Idea",
"Reply",
"Thought"
"Thought",
"DeskAnswer"
],
"type": "string"
},

View file

@ -103,7 +103,8 @@
"Research",
"Idea",
"Reply",
"Thought"
"Thought",
"DeskAnswer"
],
"type": "string"
},

View file

@ -36,7 +36,8 @@
"Research",
"Idea",
"Reply",
"Thought"
"Thought",
"DeskAnswer"
],
"type": "string"
},

View file

@ -74,7 +74,8 @@
"Research",
"Idea",
"Reply",
"Thought"
"Thought",
"DeskAnswer"
],
"type": "string"
},

View file

@ -31,7 +31,8 @@
"Research",
"Idea",
"Reply",
"Thought"
"Thought",
"DeskAnswer"
],
"type": "string"
},

View file

@ -36,7 +36,8 @@
"Research",
"Idea",
"Reply",
"Thought"
"Thought",
"DeskAnswer"
],
"type": "string"
},

View file

@ -395,6 +395,107 @@
"type": "string",
"maxLength": 10
},
"DeskAuthority": {
"type": "string",
"enum": [
"Operator",
"Steward",
"Member"
]
},
"DeskOutcome": {
"description": "The one tagged outcome of a desk answer. A retraction is a variant, so a\nretraction without a target and a target without a retraction cannot be\nstored. `of` and `retracts` name desk answers by answer id (the record key).",
"oneOf": [
{
"description": "The answer that counts for its fork when it was admitted.",
"type": "string",
"const": "Counted"
},
{
"description": "A later passing answer, kept visible; `of` is the counted answer it followed.",
"type": "object",
"properties": {
"Revision": {
"type": "object",
"properties": {
"of": {
"$ref": "#/$defs/Short"
}
},
"required": [
"of"
]
}
},
"additionalProperties": false,
"required": [
"Revision"
]
},
{
"type": "object",
"properties": {
"Retraction": {
"type": "object",
"properties": {
"retracts": {
"$ref": "#/$defs/Short"
}
},
"required": [
"retracts"
]
}
},
"additionalProperties": false,
"required": [
"Retraction"
]
},
{
"description": "A request the desk would not count, kept with what it was sent with. `retracts` is the\ntarget a refused retraction named, as sent and unchecked (it may name no answer at all);\nit is `None` on a refused answer, and it is never a citation.",
"type": "object",
"properties": {
"Refused": {
"type": "object",
"properties": {
"code": {
"$ref": "#/$defs/DeskRefusal"
},
"retracts": {
"anyOf": [
{
"$ref": "#/$defs/Short"
},
{
"type": "null"
}
]
}
},
"required": [
"code"
]
}
},
"additionalProperties": false,
"required": [
"Refused"
]
}
]
},
"DeskRefusal": {
"type": "string",
"enum": [
"StaleFork",
"NotADecider",
"ForkClosed",
"ChoiceInvalid",
"BadRetraction",
"AlreadyRetracted"
]
},
"Deviation": {
"type": "object",
"properties": {
@ -468,7 +569,7 @@
]
},
"Faculty": {
"description": "Which faculty a batch was admitted as. Attribution, not authority\n(ruling 18): no admission rule trusts it, and Cut 9's views read it back\nfrom the receipt.",
"description": "Which faculty a batch was admitted as. Attribution, not authority\n(ruling 18): no admission rule trusts it, and Cut 9's views read it back\nfrom the receipt. `Member` and `Steward` name a desk answerer who is neither the\noperator nor a faculty; they confer no admission right anywhere (a test admits a\nbatch under each and checks the outcomes match).",
"type": "string",
"enum": [
"SelfFaculty",
@ -477,7 +578,9 @@
"Soul",
"Life",
"Eyes",
"Operator"
"Operator",
"Member",
"Steward"
]
},
"FileChange": {
@ -1091,6 +1194,84 @@
"questions"
]
},
"PipelineDeskAnswer": {
"description": "One answer the fork desk's receiver passed to the Mind, refused ones included.\n`answerer` and `authority` (an attribution, never a decision) are stamped by the\nMind from the verified caller and are absent from the request type; `answered_at`\nis the client's claim kept as written, display only: order is `sequence`, the\nreceiver's order. `received_at` is the Mind's admission time (UTC) and\n`claim_in_future` is set when `answered_at` is more than five minutes after it.\n`request_id` is the client's retry token and `body_sha256` the digest of the\nrequest it came with: the pair that makes a retry the same answer, never a new\none; the key is `(answerer, request_id)`. A record holds no card: a replayed\n`stale_fork` is shown the current card. `sequence` is per question and is the\nlast part of the key (the answer id). `choice`, `note` and (for a retraction) the\ntarget are kept as sent whether the request was counted or refused, so a refused\n`choice_invalid` keeps a choice that is no option label; which answer counts now is\nderived by the reader, never stored here.",
"type": "object",
"properties": {
"answered_at": {
"$ref": "#/$defs/Short"
},
"answerer": {
"$ref": "#/$defs/Short"
},
"authority": {
"$ref": "#/$defs/DeskAuthority"
},
"body_sha256": {
"$ref": "#/$defs/Sha256Hex"
},
"campaign": {
"$ref": "#/$defs/Slug"
},
"choice": {
"anyOf": [
{
"$ref": "#/$defs/Short"
},
{
"type": "null"
}
]
},
"claim_in_future": {
"type": "boolean"
},
"fork": {
"$ref": "#/$defs/Short"
},
"note": {
"anyOf": [
{
"$ref": "#/$defs/Words"
},
{
"type": "null"
}
]
},
"options_hash": {
"$ref": "#/$defs/Sha256Hex"
},
"outcome": {
"$ref": "#/$defs/DeskOutcome"
},
"received_at": {
"$ref": "#/$defs/Short"
},
"request_id": {
"$ref": "#/$defs/RequestId"
},
"sequence": {
"type": "integer",
"format": "uint32",
"minimum": 0
}
},
"required": [
"campaign",
"fork",
"sequence",
"options_hash",
"answered_at",
"received_at",
"claim_in_future",
"request_id",
"body_sha256",
"answerer",
"authority",
"outcome"
]
},
"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": [
@ -1413,6 +1594,22 @@
"kind",
"value"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "desk_answer"
},
"value": {
"$ref": "#/$defs/PipelineDeskAnswer"
}
},
"required": [
"kind",
"value"
]
}
]
},
@ -1679,7 +1876,8 @@
"Research",
"Idea",
"Reply",
"Thought"
"Thought",
"DeskAnswer"
]
},
"PipelineMemory": {
@ -2432,6 +2630,11 @@
"builds"
]
},
"RequestId": {
"description": "A client's retry token: a lowercase D-format UUID.",
"type": "string",
"maxLength": 36
},
"ResearchSource": {
"description": "Where a research claim comes from. A `Repo` pin and a `Web` page are\noutside the document set; an `Observed` result reuses a finding's evidence;\na `Document` is the one source that is a citation edge.",
"oneOf": [
@ -2977,6 +3180,12 @@
"name",
"pins"
]
},
"Words": {
"description": "A person's words, verbatim: 1 to 4,000 UTF-8 bytes, not blank (CultLib PR #72, `answer.note`).",
"type": "string",
"maxLength": 4000,
"minLength": 1
}
}
}

View file

@ -263,7 +263,9 @@
"toward",
"run",
"reads",
"day_through"
"day_through",
"fork",
"answer"
]
},
"ClaimOutcome": {
@ -402,6 +404,107 @@
"manifest"
]
},
"DeskAuthority": {
"type": "string",
"enum": [
"Operator",
"Steward",
"Member"
]
},
"DeskOutcome": {
"description": "The one tagged outcome of a desk answer. A retraction is a variant, so a\nretraction without a target and a target without a retraction cannot be\nstored. `of` and `retracts` name desk answers by answer id (the record key).",
"oneOf": [
{
"description": "The answer that counts for its fork when it was admitted.",
"type": "string",
"const": "Counted"
},
{
"description": "A later passing answer, kept visible; `of` is the counted answer it followed.",
"type": "object",
"properties": {
"Revision": {
"type": "object",
"properties": {
"of": {
"$ref": "#/$defs/Short"
}
},
"required": [
"of"
]
}
},
"additionalProperties": false,
"required": [
"Revision"
]
},
{
"type": "object",
"properties": {
"Retraction": {
"type": "object",
"properties": {
"retracts": {
"$ref": "#/$defs/Short"
}
},
"required": [
"retracts"
]
}
},
"additionalProperties": false,
"required": [
"Retraction"
]
},
{
"description": "A request the desk would not count, kept with what it was sent with. `retracts` is the\ntarget a refused retraction named, as sent and unchecked (it may name no answer at all);\nit is `None` on a refused answer, and it is never a citation.",
"type": "object",
"properties": {
"Refused": {
"type": "object",
"properties": {
"code": {
"$ref": "#/$defs/DeskRefusal"
},
"retracts": {
"anyOf": [
{
"$ref": "#/$defs/Short"
},
{
"type": "null"
}
]
}
},
"required": [
"code"
]
}
},
"additionalProperties": false,
"required": [
"Refused"
]
}
]
},
"DeskRefusal": {
"type": "string",
"enum": [
"StaleFork",
"NotADecider",
"ForkClosed",
"ChoiceInvalid",
"BadRetraction",
"AlreadyRetracted"
]
},
"Deviation": {
"type": "object",
"properties": {
@ -475,7 +578,7 @@
]
},
"Faculty": {
"description": "Which faculty a batch was admitted as. Attribution, not authority\n(ruling 18): no admission rule trusts it, and Cut 9's views read it back\nfrom the receipt.",
"description": "Which faculty a batch was admitted as. Attribution, not authority\n(ruling 18): no admission rule trusts it, and Cut 9's views read it back\nfrom the receipt. `Member` and `Steward` name a desk answerer who is neither the\noperator nor a faculty; they confer no admission right anywhere (a test admits a\nbatch under each and checks the outcomes match).",
"type": "string",
"enum": [
"SelfFaculty",
@ -484,7 +587,9 @@
"Soul",
"Life",
"Eyes",
"Operator"
"Operator",
"Member",
"Steward"
]
},
"FileChange": {
@ -1400,6 +1505,69 @@
"QuoteWithoutOperator"
]
},
{
"description": "A `desk_answer` reached a door other than the desk's (an admit, an import or a derivation): `id` is its key.",
"type": "object",
"properties": {
"DeskAnswerOutsideDesk": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"required": [
"id"
]
}
},
"additionalProperties": false,
"required": [
"DeskAnswerOutsideDesk"
]
},
{
"description": "A desk request the Mind cannot read as an answer: `field` names the part (the fork, `answered_at`, an answer carrying neither choice nor note, a malformed answer id).",
"type": "object",
"properties": {
"DeskRequestInvalid": {
"type": "object",
"properties": {
"field": {
"type": "string"
}
},
"required": [
"field"
]
}
},
"additionalProperties": false,
"required": [
"DeskRequestInvalid"
]
},
{
"description": "The desk's `request_id_conflict` (CultLib PR #72): this `(answerer, request_id)` already bound another body. Nothing is stored.",
"type": "object",
"properties": {
"DeskRequestIdConflict": {
"type": "object",
"properties": {
"request_id": {
"type": "string"
}
},
"required": [
"request_id"
]
}
},
"additionalProperties": false,
"required": [
"DeskRequestIdConflict"
]
},
{
"description": "`campaign` names the refused document: a campaign, or a target revision that empties its predecessor's repos.",
"type": "object",
@ -2529,6 +2697,84 @@
"questions"
]
},
"PipelineDeskAnswer": {
"description": "One answer the fork desk's receiver passed to the Mind, refused ones included.\n`answerer` and `authority` (an attribution, never a decision) are stamped by the\nMind from the verified caller and are absent from the request type; `answered_at`\nis the client's claim kept as written, display only: order is `sequence`, the\nreceiver's order. `received_at` is the Mind's admission time (UTC) and\n`claim_in_future` is set when `answered_at` is more than five minutes after it.\n`request_id` is the client's retry token and `body_sha256` the digest of the\nrequest it came with: the pair that makes a retry the same answer, never a new\none; the key is `(answerer, request_id)`. A record holds no card: a replayed\n`stale_fork` is shown the current card. `sequence` is per question and is the\nlast part of the key (the answer id). `choice`, `note` and (for a retraction) the\ntarget are kept as sent whether the request was counted or refused, so a refused\n`choice_invalid` keeps a choice that is no option label; which answer counts now is\nderived by the reader, never stored here.",
"type": "object",
"properties": {
"answered_at": {
"$ref": "#/$defs/Short"
},
"answerer": {
"$ref": "#/$defs/Short"
},
"authority": {
"$ref": "#/$defs/DeskAuthority"
},
"body_sha256": {
"$ref": "#/$defs/Sha256Hex"
},
"campaign": {
"$ref": "#/$defs/Slug"
},
"choice": {
"anyOf": [
{
"$ref": "#/$defs/Short"
},
{
"type": "null"
}
]
},
"claim_in_future": {
"type": "boolean"
},
"fork": {
"$ref": "#/$defs/Short"
},
"note": {
"anyOf": [
{
"$ref": "#/$defs/Words"
},
{
"type": "null"
}
]
},
"options_hash": {
"$ref": "#/$defs/Sha256Hex"
},
"outcome": {
"$ref": "#/$defs/DeskOutcome"
},
"received_at": {
"$ref": "#/$defs/Short"
},
"request_id": {
"$ref": "#/$defs/RequestId"
},
"sequence": {
"type": "integer",
"format": "uint32",
"minimum": 0
}
},
"required": [
"campaign",
"fork",
"sequence",
"options_hash",
"answered_at",
"received_at",
"claim_in_future",
"request_id",
"body_sha256",
"answerer",
"authority",
"outcome"
]
},
"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": [
@ -2851,6 +3097,22 @@
"kind",
"value"
]
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "desk_answer"
},
"value": {
"$ref": "#/$defs/PipelineDeskAnswer"
}
},
"required": [
"kind",
"value"
]
}
]
},
@ -3781,6 +4043,49 @@
"kind",
"value"
]
},
{
"description": "`outcome` is the stored, write-time outcome's variant name. `counts` is whether this answer is\nthe one that counts for its fork at the read's snapshot, derived by `counted_answer` and never\nstored: a retracted `Counted` answer reads `counts: false`, the promoted next answer `true`.",
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "desk_answer"
},
"value": {
"type": "object",
"properties": {
"authority": {
"$ref": "#/$defs/DeskAuthority"
},
"counts": {
"type": "boolean"
},
"fork": {
"$ref": "#/$defs/Short"
},
"outcome": {
"$ref": "#/$defs/Short"
},
"sequence": {
"type": "integer",
"format": "uint32",
"minimum": 0
}
},
"required": [
"fork",
"sequence",
"outcome",
"counts",
"authority"
]
}
},
"required": [
"kind",
"value"
]
}
]
},
@ -4047,7 +4352,8 @@
"Research",
"Idea",
"Reply",
"Thought"
"Thought",
"DeskAnswer"
]
},
"PipelineMemory": {
@ -5257,6 +5563,11 @@
"builds"
]
},
"RequestId": {
"description": "A client's retry token: a lowercase D-format UUID.",
"type": "string",
"maxLength": 36
},
"ResearchSource": {
"description": "Where a research claim comes from. A `Repo` pin and a `Web` page are\noutside the document set; an `Observed` result reuses a finding's evidence;\na `Document` is the one source that is a citation edge.",
"oneOf": [
@ -5861,6 +6172,12 @@
"name",
"pins"
]
},
"Words": {
"description": "A person's words, verbatim: 1 to 4,000 UTF-8 bytes, not blank (CultLib PR #72, `answer.note`).",
"type": "string",
"maxLength": 4000,
"minLength": 1
}
}
}

View file

@ -232,6 +232,17 @@
"title": "Epiphany Pipeline Thought v2",
"path": "epiphany.pipeline.thought.v2.schema.json"
},
{
"schemaId": "https://gamecult.dev/epiphany/cultnet/epiphany.pipeline.desk_answer.v2.schema.json",
"kind": "document_payload",
"wireContracts": [
"cultnet.schema.v0"
],
"schemaVersion": "epiphany.pipeline.desk_answer.v2",
"documentType": "epiphany.pipeline.desk_answer.v2",
"title": "Epiphany Pipeline DeskAnswer v2",
"path": "epiphany.pipeline.desk_answer.v2.schema.json"
},
{
"schemaId": "huginn.mind_request.v1",
"kind": "wire_message",