huginn-mind: Member and Steward faculties for desk receipts; pin the body digest, lookup position and sub-second claim
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
368217a65a
commit
3a1677251b
5 changed files with 111 additions and 11 deletions
|
|
@ -176,8 +176,15 @@ fn refusal_of(docs: &Docs<'_>, question: &PipelineQuestion, stamp: &DeskStamp, r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The digest of the request as the client sent it: what "the same body" means for a
|
/// The one definition of "the same body": Huginn's own SHA-256 over the canonical body of the
|
||||||
/// retry. Every part of the request but the id itself.
|
/// request, which is every part of it but the id itself and so includes `answered_at` and, for a
|
||||||
|
/// retraction, the target. It is never read from the client. It decides a replay (equal), a
|
||||||
|
/// `request_id_conflict` (unequal under a bound id) and nothing else; no other code compares
|
||||||
|
/// bodies, a refused retraction's target included, which the stored `outcome` already carries.
|
||||||
|
///
|
||||||
|
/// FROZEN: the digest form (field order, `serde_json` string of `Sent`, lowercase hex) is stored
|
||||||
|
/// in every answer and read back by retries years later. A change of form is a new version of the
|
||||||
|
/// stored field, never an edit here; `body_digest_is_pinned` fails on any edit.
|
||||||
fn body_digest(request: &DeskAnswerRequest) -> Sha256Hex {
|
fn body_digest(request: &DeskAnswerRequest) -> Sha256Hex {
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
|
|
@ -302,9 +309,14 @@ impl<S: MindStore> Mind<S> {
|
||||||
let sequence = answers_of(&docs, fork).map(|(_, held)| held.sequence).max().unwrap_or(0).checked_add(1).ok_or_else(|| invalid("sequence"))?;
|
let sequence = answers_of(&docs, fork).map(|(_, held)| held.sequence).max().unwrap_or(0).checked_add(1).ok_or_else(|| invalid("sequence"))?;
|
||||||
let document = PipelineDocument::DeskAnswer(answer(sequence, outcome.clone()));
|
let document = PipelineDocument::DeskAnswer(answer(sequence, outcome.clone()));
|
||||||
let envelope = prepare(&document, self.cache())?;
|
let envelope = prepare(&document, self.cache())?;
|
||||||
// The receipt names the operator only when the verified account is the operator's.
|
// The receipt names the operator only when the verified account is the operator's; a
|
||||||
|
// steward or member answerer is named as such. Attribution only: admission reads no faculty.
|
||||||
let provenance = PipelineProvenance {
|
let provenance = PipelineProvenance {
|
||||||
faculty: if stamp.authority == DeskAuthority::Operator { Faculty::Operator } else { Faculty::SelfFaculty },
|
faculty: match stamp.authority {
|
||||||
|
DeskAuthority::Operator => Faculty::Operator,
|
||||||
|
DeskAuthority::Steward => Faculty::Steward,
|
||||||
|
DeskAuthority::Member => Faculty::Member,
|
||||||
|
},
|
||||||
agent: stamp.answerer.clone(),
|
agent: stamp.answerer.clone(),
|
||||||
session: "desk".into(),
|
session: "desk".into(),
|
||||||
tool: "huginn-desk".into(),
|
tool: "huginn-desk".into(),
|
||||||
|
|
@ -887,7 +899,7 @@ mod tests {
|
||||||
fn the_receipt_names_the_operator_only_for_the_operator_account() {
|
fn the_receipt_names_the_operator_only_for_the_operator_account() {
|
||||||
let at = "2026-11-10T12:00:00Z";
|
let at = "2026-11-10T12:00:00Z";
|
||||||
let mut mind = desk();
|
let mut mind = desk();
|
||||||
for (authority, faculty) in [(DeskAuthority::Operator, Faculty::Operator), (DeskAuthority::Steward, Faculty::SelfFaculty), (DeskAuthority::Member, Faculty::SelfFaculty)] {
|
for (authority, faculty) in [(DeskAuthority::Operator, Faculty::Operator), (DeskAuthority::Steward, Faculty::Steward), (DeskAuthority::Member, Faculty::Member)] {
|
||||||
let outcome = ask(&mut mind, &stamp_as("x", authority, &LIST), pick(FORK, "Q1", "A", at));
|
let outcome = ask(&mut mind, &stamp_as("x", authority, &LIST), pick(FORK, "Q1", "A", at));
|
||||||
let DeskAnswerOutcome::Stored { receipt_id, .. } = outcome else { panic!("{outcome:?}") };
|
let DeskAnswerOutcome::Stored { receipt_id, .. } = outcome else { panic!("{outcome:?}") };
|
||||||
let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap();
|
let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap();
|
||||||
|
|
@ -1134,4 +1146,84 @@ mod tests {
|
||||||
assert_eq!(head(&mind), before);
|
assert_eq!(head(&mind), before);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- fix batch 3 ----
|
||||||
|
|
||||||
|
/// Faculty is attribution: a batch admitted under any faculty, Member and Steward included,
|
||||||
|
/// gets the outcome Hands gets, and no faculty opens the desk-answer door.
|
||||||
|
#[test]
|
||||||
|
fn member_and_steward_confer_no_admission_right() {
|
||||||
|
let all = [Faculty::SelfFaculty, Faculty::Imagination, Faculty::Hands, Faculty::Soul, Faculty::Life, Faculty::Eyes, Faculty::Operator, Faculty::Member, Faculty::Steward];
|
||||||
|
let outcome_of = |faculty: Faculty| {
|
||||||
|
let mut mind = desk();
|
||||||
|
let mut answer = held_after_one(&mut mind);
|
||||||
|
answer.request_id = fresh_id();
|
||||||
|
answer.sequence = 2;
|
||||||
|
let refused = admit_as(&mut mind, faculty, vec![D::DeskAnswer(answer)]);
|
||||||
|
let ordinary = admit_as(&mut mind, faculty, vec![question("Q9", &["A", "B"], "A")]);
|
||||||
|
(format!("{refused:?}"), matches!(ordinary, PipelineAdmissionOutcome::Committed { .. }))
|
||||||
|
};
|
||||||
|
let baseline = outcome_of(Faculty::Hands);
|
||||||
|
assert!(baseline.1 && baseline.0.contains("Refused"), "{baseline:?}");
|
||||||
|
for faculty in all {
|
||||||
|
assert_eq!(outcome_of(faculty), baseline, "{faculty:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn held_after_one(mind: &mut Mind<MemoryStore>) -> PipelineDeskAnswer {
|
||||||
|
let (answer, _) = stored(ask(mind, &decider("x"), pick(FORK, "Q1", "A", AT)));
|
||||||
|
held(mind, &answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The digest form is frozen: a fixed request digests to a fixed hex, computed outside this code.
|
||||||
|
#[test]
|
||||||
|
fn body_digest_is_pinned() {
|
||||||
|
let request = |body: DeskBody| DeskAnswerRequest {
|
||||||
|
fork: s("eureka-state:question:Q1"),
|
||||||
|
options_hash: Sha256Hex("a".repeat(64)),
|
||||||
|
body,
|
||||||
|
answered_at: s("2026-11-10T12:00:00Z"),
|
||||||
|
request_id: RequestId(R1.into()),
|
||||||
|
};
|
||||||
|
let answer = request(DeskBody::Answer { choice: Some(l("A")), note: None });
|
||||||
|
assert_eq!(body_digest(&answer).0, "8a4d5b2ed0b3452bfed2f3a276b2828974e6824a3edcc643cfe9cfcdda34173a");
|
||||||
|
let retraction = request(DeskBody::Retraction { retracts: s("eureka-state:desk_answer:Q1.1") });
|
||||||
|
assert_eq!(body_digest(&retraction).0, "9f8075382556898c0bea364ed7c6deb2a86d8c78288a1f34e06da6e9bf5f58a3");
|
||||||
|
// The id is not part of the body; answered_at is.
|
||||||
|
let mut other_id = answer.clone();
|
||||||
|
other_id.request_id = RequestId(R2.into());
|
||||||
|
assert_eq!(body_digest(&other_id), body_digest(&answer));
|
||||||
|
let mut other_time = answer.clone();
|
||||||
|
other_time.answered_at = s("2026-11-10T12:00:01Z");
|
||||||
|
assert_ne!(body_digest(&other_time), body_digest(&answer));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The retry lookup follows the shape check: a malformed body reusing a bound id is
|
||||||
|
/// desk_request_invalid, never a conflict, and stores nothing.
|
||||||
|
#[test]
|
||||||
|
fn the_lookup_comes_after_the_shape_check() {
|
||||||
|
let mut mind = desk();
|
||||||
|
stored(ask(&mut mind, &decider("x"), with_id(pick(FORK, "Q1", "A", AT), R1)));
|
||||||
|
let before = head(&mind);
|
||||||
|
let mut blank = with_id(pick(FORK, "Q1", "A", AT), R1);
|
||||||
|
blank.body = DeskBody::Answer { choice: None, note: None };
|
||||||
|
let mut bad_time = with_id(pick(FORK, "Q1", "A", AT), R1);
|
||||||
|
bad_time.answered_at = s("yesterday");
|
||||||
|
let mut bad_fork = with_id(pick(FORK, "Q1", "A", AT), R1);
|
||||||
|
bad_fork.fork = s("not-a-question");
|
||||||
|
for (request, field) in [(blank, "body"), (bad_time, "answered_at"), (bad_fork, "fork")] {
|
||||||
|
assert_eq!(ask(&mut mind, &decider("x"), request), DeskAnswerOutcome::Refused(MindRefusal::DeskRequestInvalid { field: field.into() }), "{field}");
|
||||||
|
}
|
||||||
|
assert_eq!(head(&mind), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// claim_in_future compares at sub-second precision: 300.0 s ahead is not flagged, 300.5 s is.
|
||||||
|
#[test]
|
||||||
|
fn claim_in_future_is_decided_below_the_second() {
|
||||||
|
let mut mind = desk();
|
||||||
|
for (claim, flagged) in [("2026-09-16T12:05:00.000Z", false), ("2026-09-16T12:05:00.500Z", true), ("2026-09-16T12:04:59.900Z", false)] {
|
||||||
|
let (answer, _) = stored(ask(&mut mind, &decider("x"), pick(FORK, "Q1", "A", claim)));
|
||||||
|
assert_eq!(held(&mind, &answer).claim_in_future, flagged, "{claim}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// 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
|
/// (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)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
pub enum Faculty {
|
pub enum Faculty {
|
||||||
SelfFaculty,
|
SelfFaculty,
|
||||||
|
|
@ -40,6 +42,8 @@ pub enum Faculty {
|
||||||
Life,
|
Life,
|
||||||
Eyes,
|
Eyes,
|
||||||
Operator,
|
Operator,
|
||||||
|
Member,
|
||||||
|
Steward,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Who admitted a batch: the faculty, the agent, its session and the tool.
|
/// Who admitted a batch: the faculty, the agent, its session and the tool.
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ pub(crate) enum Alias {
|
||||||
Pass,
|
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 OUTCOMES: [&str; 8] = ["Superseded", "Answered", "Fixed", "Deferred", "Recorded", "Withdrawn", "Slept", "Moved"];
|
||||||
const BOOLEANS: [&str; 2] = ["true", "false"];
|
const BOOLEANS: [&str; 2] = ["true", "false"];
|
||||||
const MEMORY_ORIGINS: [&str; 5] = ["Room", "Direct", "Wake", "SelfRun", "Sleep"];
|
const MEMORY_ORIGINS: [&str; 5] = ["Room", "Direct", "Wake", "SelfRun", "Sleep"];
|
||||||
|
|
|
||||||
|
|
@ -558,7 +558,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Faculty": {
|
"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",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"SelfFaculty",
|
"SelfFaculty",
|
||||||
|
|
@ -567,7 +567,9 @@
|
||||||
"Soul",
|
"Soul",
|
||||||
"Life",
|
"Life",
|
||||||
"Eyes",
|
"Eyes",
|
||||||
"Operator"
|
"Operator",
|
||||||
|
"Member",
|
||||||
|
"Steward"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"FileChange": {
|
"FileChange": {
|
||||||
|
|
|
||||||
|
|
@ -567,7 +567,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Faculty": {
|
"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",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"SelfFaculty",
|
"SelfFaculty",
|
||||||
|
|
@ -576,7 +576,9 @@
|
||||||
"Soul",
|
"Soul",
|
||||||
"Life",
|
"Life",
|
||||||
"Eyes",
|
"Eyes",
|
||||||
"Operator"
|
"Operator",
|
||||||
|
"Member",
|
||||||
|
"Steward"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"FileChange": {
|
"FileChange": {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue