Take the sequenced keys and make in force mean what it says
The pin moves to Epiphany `d5a36c2a`, where a resolution's key ends in its per-subject sequence and a stewardship's in its per-(instance, repo) one. Two fields arrive with it, so `stewardship` and `resolution` in the fixtures set `sequence: 1` and `stewardship_n`/`resolution_n` say otherwise when a test needs a second record. Every literal key that gained `.n1` moved with it, `id_of` included. The rules the sequence was for: - `in_force` is recursive. A document is in force when no resolution that is itself in force names it, so a withdrawn resolution stops closing its subject. The old one-level version counted a withdrawal as if it still stood, which is half of why a reopened subject could not be resolved again. - The other half was A10. `refuse_collisions` no longer has a `Resolution` arm: a resolution key is not one-per-subject any more, so a collision on it means what it means for every other kind, and whether a subject is closed is A8's question. - A8 asks it, for both sequenced kinds, in the same two steps. The sequence is the writer's and must be the previous plus one over image and batch, counting every record of that scope but this document -- `ResolutionOutOfSequence`, `StewardshipOutOfSequence`. Then no record of that scope may still be in force -- `AlreadyResolved`, `AlreadyStewarded`. With both, at most one stewardship of a repo stands on a mind, which is what `stewardship_of` has been assuming. - Q19 A is enforced rather than described: a resolution whose subject is a resolution of a resolution is `IncompatibleResolution`. A withdrawal cannot be withdrawn; a closure is reinstated by resolving the subject again. - `derive` sets a sequence on each write it makes, from the same image the rule reads. Two existing tests move with the rules. `subject_resolves_at_most_once` is now `a_subject_with_a_resolution_in_force_refuses_another` and separates the two refusals. The hand-off test's last case used to be an A4 collision between a batch's own stewardship and the derived one; the derived write now takes the sequence after the batch's, so there is nothing left to collide and the batch is refused as out of sequence instead. 31 tests, 0 warnings; `cargo check --workspace` clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
30daff8822
commit
3efc0dc82d
5 changed files with 181 additions and 63 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -146,7 +146,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
|||
[[package]]
|
||||
name = "epiphany-pipeline"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/GameCult/Epiphany.git?rev=a65c6420f681d6641b842f94b319f0fa14166c96#a65c6420f681d6641b842f94b319f0fa14166c96"
|
||||
source = "git+https://github.com/GameCult/Epiphany.git?rev=d5a36c2a291b910539072717d42d61a9aa7485d1#d5a36c2a291b910539072717d42d61a9aa7485d1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ publish.workspace = true
|
|||
anyhow = "1"
|
||||
chrono = "0.4.44"
|
||||
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a0813c6eed24d30bf88073ef615b633c77ebfcd6", package = "cultcache-rs" }
|
||||
epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "a65c6420f681d6641b842f94b319f0fa14166c96", package = "epiphany-pipeline" }
|
||||
epiphany-pipeline = { git = "https://github.com/GameCult/Epiphany.git", rev = "d5a36c2a291b910539072717d42d61a9aa7485d1", package = "epiphany-pipeline" }
|
||||
rmp-serde = "1"
|
||||
schemars = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@
|
|||
//!
|
||||
//! Every cross-field and cross-document rule lives here and nowhere else:
|
||||
//! the leaf never gains one, the daemon and the client never re-derive one.
|
||||
//! "In force" means no resolution names the document in image or batch; it
|
||||
//! is computed at rule time and never stored.
|
||||
//! "In force" is recursive: a document is in force when no resolution that is
|
||||
//! itself in force names it, so a withdrawn resolution stops closing its
|
||||
//! subject. It is computed at rule time over image and batch, never stored.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
|
|
@ -245,42 +246,80 @@ impl Docs {
|
|||
batch.chain(image)
|
||||
}
|
||||
|
||||
fn resolutions(&self) -> impl Iterator<Item = &PipelineResolution> {
|
||||
self.of_kind(PipelineKind::Resolution).filter_map(|(_, document)| match document {
|
||||
PipelineDocument::Resolution(resolution) => Some(resolution),
|
||||
fn resolutions(&self) -> impl Iterator<Item = (&str, &PipelineResolution)> {
|
||||
self.of_kind(PipelineKind::Resolution).filter_map(|(key, document)| match document {
|
||||
PipelineDocument::Resolution(resolution) => Some((key, resolution)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// No resolution in image or batch names the document.
|
||||
fn stewardships(&self) -> impl Iterator<Item = (&str, &PipelineStewardship)> {
|
||||
self.of_kind(PipelineKind::Stewardship).filter_map(|(key, document)| match document {
|
||||
PipelineDocument::Stewardship(stewardship) => Some((key, stewardship)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The greatest sequence among a subject's resolutions in image or batch,
|
||||
/// `0` when it has none, so the next is `latest + 1`. `own` is the
|
||||
/// document being checked, excluded by content identity so that an exact
|
||||
/// replay measures the same `latest` the first admission did.
|
||||
fn latest_resolution(&self, subject: &PipelineRef, own: Option<&PipelineResolution>) -> u32 {
|
||||
self.resolutions()
|
||||
.filter(|(_, resolution)| resolution.subject == *subject && own != Some(*resolution))
|
||||
.map(|(_, resolution)| resolution.sequence)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The same for a repo's assignments to one mind.
|
||||
fn latest_stewardship(&self, mind: &Slug, repo: &OrgRepo, own: Option<&PipelineStewardship>) -> u32 {
|
||||
self.stewardships()
|
||||
.filter(|(_, stewardship)| stewardship.instance == *mind && stewardship.repo == *repo && own != Some(*stewardship))
|
||||
.map(|(_, stewardship)| stewardship.sequence)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// No resolution that is itself in force names the document. The
|
||||
/// recursion is the whole rule: a withdrawn resolution stops counting, so
|
||||
/// its subject is open again. `key(R)` is strictly longer than the id it
|
||||
/// resolves, so the chain is bounded and this terminates.
|
||||
fn in_force(&self, kind: PipelineKind, id: &str) -> bool {
|
||||
self.in_force_unless(kind, id, |_| false)
|
||||
}
|
||||
|
||||
/// In force, ignoring a resolution the caller's own document derives:
|
||||
/// the ruling that answers a question is what resolves it, and the
|
||||
/// hand-off that withdraws a stewardship is what resolves that.
|
||||
fn in_force_unless(&self, kind: PipelineKind, id: &str, own: impl Fn(&ResolutionOutcome) -> bool) -> bool {
|
||||
!self
|
||||
.resolutions()
|
||||
.any(|resolution| resolution.subject.kind == kind && resolution.subject.id.0 == id && !own(&resolution.outcome))
|
||||
/// In force, ignoring a resolution the caller's own document is or
|
||||
/// derives: the ruling that answers a question is what resolves it, the
|
||||
/// hand-off that withdraws a stewardship is what resolves that, and a
|
||||
/// resolution does not resolve its own subject out from under itself.
|
||||
fn in_force_unless(&self, kind: PipelineKind, id: &str, own: impl Fn(&PipelineResolution) -> bool) -> bool {
|
||||
!self.resolutions().any(|(key, resolution)| {
|
||||
resolution.subject.kind == kind
|
||||
&& resolution.subject.id.0 == id
|
||||
&& !own(resolution)
|
||||
&& self.in_force(PipelineKind::Resolution, key)
|
||||
})
|
||||
}
|
||||
|
||||
/// The in-force stewardship of `(mind, repo)`, if any, ignoring the
|
||||
/// withdrawal `hand_off_key` derives.
|
||||
fn stewardship_of(&self, mind: &Slug, repo: &OrgRepo, hand_off_key: Option<&str>) -> Option<(&str, &PipelineStewardship)> {
|
||||
self.of_kind(PipelineKind::Stewardship).find_map(|(key, document)| match document {
|
||||
PipelineDocument::Stewardship(stewardship)
|
||||
if stewardship.instance == *mind
|
||||
/// Every in-force stewardship of `(mind, repo)`, ignoring the withdrawal
|
||||
/// `hand_off_key` derives. At most one is in force once the stewardship
|
||||
/// row holds, which is why `stewardship_of` needs no tie-break.
|
||||
fn stewardships_of(&self, mind: &Slug, repo: &OrgRepo, hand_off_key: Option<&str>) -> Vec<(&str, &PipelineStewardship)> {
|
||||
self.stewardships()
|
||||
.filter(|(key, stewardship)| {
|
||||
stewardship.instance == *mind
|
||||
&& stewardship.repo == *repo
|
||||
&& self.in_force_unless(PipelineKind::Stewardship, key, |outcome| {
|
||||
matches!(outcome, ResolutionOutcome::Withdrawn { reason } if Some(reason.0.as_str()) == hand_off_key)
|
||||
}) =>
|
||||
{
|
||||
Some((key, stewardship))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
&& self.in_force_unless(PipelineKind::Stewardship, key, |resolution| {
|
||||
matches!(&resolution.outcome, ResolutionOutcome::Withdrawn { reason } if Some(reason.0.as_str()) == hand_off_key)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The in-force stewardship of `(mind, repo)`, if any.
|
||||
fn stewardship_of(&self, mind: &Slug, repo: &OrgRepo, hand_off_key: Option<&str>) -> Option<(&str, &PipelineStewardship)> {
|
||||
self.stewardships_of(mind, repo, hand_off_key).first().copied()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -422,8 +461,10 @@ fn derive(docs: &Docs, mind: &Slug) -> Vec<PipelineDocument> {
|
|||
match &staged.document {
|
||||
D::Ruling(ruling) => {
|
||||
if let Some(question) = &ruling.answers {
|
||||
let subject = PipelineRef { kind: K::Question, id: question.clone() };
|
||||
derived.push(D::Resolution(PipelineResolution {
|
||||
subject: PipelineRef { kind: K::Question, id: question.clone() },
|
||||
sequence: docs.latest_resolution(&subject, None) + 1,
|
||||
subject,
|
||||
outcome: ResolutionOutcome::Answered { by: PipelineRef { kind: K::Ruling, id: Short(staged.key.clone()) } },
|
||||
rationale: ruling.ruling.clone(),
|
||||
resolved_on: ruling.ruled_on.clone(),
|
||||
|
|
@ -434,8 +475,10 @@ fn derive(docs: &Docs, mind: &Slug) -> Vec<PipelineDocument> {
|
|||
if hand_off.from_instance == *mind
|
||||
&& let Some((stewardship_key, _)) = docs.stewardship_of(mind, &hand_off.repo, Some(&staged.key))
|
||||
{
|
||||
let subject = PipelineRef { kind: K::Stewardship, id: Short(stewardship_key.to_string()) };
|
||||
derived.push(D::Resolution(PipelineResolution {
|
||||
subject: PipelineRef { kind: K::Stewardship, id: Short(stewardship_key.to_string()) },
|
||||
sequence: docs.latest_resolution(&subject, None) + 1,
|
||||
subject,
|
||||
outcome: ResolutionOutcome::Withdrawn { reason: Line(staged.key.clone()) },
|
||||
rationale: hand_off.reason.clone(),
|
||||
resolved_on: hand_off.handed_on.clone(),
|
||||
|
|
@ -444,6 +487,7 @@ fn derive(docs: &Docs, mind: &Slug) -> Vec<PipelineDocument> {
|
|||
if hand_off.to_instance == *mind {
|
||||
derived.push(D::Stewardship(PipelineStewardship {
|
||||
instance: mind.clone(),
|
||||
sequence: docs.latest_stewardship(mind, &hand_off.repo, None) + 1,
|
||||
repo: hand_off.repo.clone(),
|
||||
assigned_on: hand_off.handed_on.clone(),
|
||||
note: Line(staged.key.clone()),
|
||||
|
|
@ -493,8 +537,8 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> {
|
|||
let Some(D::Question(question)) = docs.find(K::Question, &question_id.0) else {
|
||||
return Err(MindRefusal::MissingReference { kind: K::Question, id: question_id.0.clone() });
|
||||
};
|
||||
let own = |outcome: &ResolutionOutcome| {
|
||||
matches!(outcome, ResolutionOutcome::Answered { by } if by.kind == K::Ruling && by.id.0 == key)
|
||||
let own = |resolution: &PipelineResolution| {
|
||||
matches!(&resolution.outcome, ResolutionOutcome::Answered { by } if by.kind == K::Ruling && by.id.0 == key)
|
||||
};
|
||||
if !docs.in_force_unless(K::Question, &question_id.0, own) {
|
||||
return Err(MindRefusal::AlreadyResolved { subject: question_id.0.clone() });
|
||||
|
|
@ -589,13 +633,27 @@ fn check(docs: &Docs, staged: &Staged, mind: &Slug) -> Result<(), MindRefusal> {
|
|||
Ok(())
|
||||
}
|
||||
D::Resolution(resolution) => resolution_rule(docs, resolution),
|
||||
D::Stewardship(stewardship) => {
|
||||
let expected = docs.latest_stewardship(&stewardship.instance, &stewardship.repo, Some(stewardship)) + 1;
|
||||
if stewardship.sequence != expected {
|
||||
return Err(MindRefusal::StewardshipOutOfSequence {
|
||||
repo: stewardship.repo.0.clone(),
|
||||
expected,
|
||||
actual: stewardship.sequence,
|
||||
});
|
||||
}
|
||||
if docs.stewardships_of(mind, &stewardship.repo, None).iter().any(|(other, _)| *other != key) {
|
||||
return Err(MindRefusal::AlreadyStewarded { repo: stewardship.repo.0.clone() });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
D::HandOff(hand_off) => {
|
||||
if hand_off.from_instance == *mind && docs.stewardship_of(mind, &hand_off.repo, Some(key)).is_none() {
|
||||
return Err(MindRefusal::NotStewarded { repo: hand_off.repo.0.clone() });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
D::FollowUp(_) | D::Instance(_) | D::Stewardship(_) => Ok(()),
|
||||
D::FollowUp(_) | D::Instance(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -648,9 +706,10 @@ fn outcome_name(outcome: &ResolutionOutcome) -> &'static str {
|
|||
/// A resolution is itself resolvable, by withdrawal alone: the operator's
|
||||
/// ruling on Q17 keeps a withdrawn resolution attached to its subject rather
|
||||
/// than erasing it, and a subject whose resolution is withdrawn may be
|
||||
/// resolved again. The second resolution needs a key the first does not
|
||||
/// already hold, which is a sequence in the leaf's resolution key and is
|
||||
/// mapped separately.
|
||||
/// resolved again at the next sequence, which the leaf's key now carries. The
|
||||
/// chain stops there: Q19 A caps it at depth two, in `resolution_rule`, since
|
||||
/// a withdrawal that could itself be withdrawn would leave two closures
|
||||
/// standing over one subject.
|
||||
fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool {
|
||||
use PipelineKind as K;
|
||||
use ResolutionOutcome as O;
|
||||
|
|
@ -676,9 +735,10 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool {
|
|||
fits
|
||||
}
|
||||
|
||||
/// The resolution row: the matrix, a non-empty supersession, every referent
|
||||
/// in force, and the two coherence rules (an `Answered` ruling answers this
|
||||
/// question; a cut spec is superseded within its cut).
|
||||
/// The resolution row: the sequence, the in-force subject, the matrix, the
|
||||
/// Q19 cap, a non-empty supersession, every referent in force, and the two
|
||||
/// coherence rules (an `Answered` ruling answers this question; a cut spec is
|
||||
/// superseded within its cut).
|
||||
fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), MindRefusal> {
|
||||
use PipelineDocument as D;
|
||||
use PipelineKind as K;
|
||||
|
|
@ -687,10 +747,34 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M
|
|||
if let ResolutionOutcome::Superseded { by } = &resolution.outcome && by.is_empty() {
|
||||
return Err(MindRefusal::EmptySupersession);
|
||||
}
|
||||
// The sequence is the writer's and is checked against the image, as a
|
||||
// revision is: the previous plus one, counting every record of this
|
||||
// subject other than this document itself.
|
||||
let expected = docs.latest_resolution(&resolution.subject, Some(resolution)) + 1;
|
||||
if resolution.sequence != expected {
|
||||
return Err(MindRefusal::ResolutionOutOfSequence {
|
||||
subject: subject_id.into(),
|
||||
expected,
|
||||
actual: resolution.sequence,
|
||||
});
|
||||
}
|
||||
// A8: a subject with a resolution still in force is closed. A withdrawn
|
||||
// one is not in force, so the subject is open and this is its next record.
|
||||
if !docs.in_force_unless(subject_kind, subject_id, |other| other == resolution) {
|
||||
return Err(MindRefusal::AlreadyResolved { subject: subject_id.into() });
|
||||
}
|
||||
let incompatible = || MindRefusal::IncompatibleResolution { subject_kind, outcome: outcome_name(&resolution.outcome).into() };
|
||||
if !matrix(subject_kind, &resolution.outcome) {
|
||||
return Err(incompatible());
|
||||
}
|
||||
// Q19 A: the chain stops at depth two. A withdrawal cannot be withdrawn;
|
||||
// to reinstate a closure, resolve the subject again at the next sequence.
|
||||
if subject_kind == K::Resolution
|
||||
&& let Some(D::Resolution(subject)) = docs.find(K::Resolution, subject_id)
|
||||
&& subject.subject.kind == K::Resolution
|
||||
{
|
||||
return Err(incompatible());
|
||||
}
|
||||
for referent in outcome_references(&resolution.outcome) {
|
||||
if !docs.in_force(referent.kind, &referent.id) {
|
||||
return Err(MindRefusal::CitesResolvedDocument { kind: referent.kind, id: referent.id });
|
||||
|
|
@ -718,16 +802,13 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// A10: a write whose identity the image already holds. A resolution's key
|
||||
/// is outcome-invariant, so its collision means the subject is already
|
||||
/// resolved.
|
||||
/// A10: a write whose identity the image already holds. Every kind answers
|
||||
/// the same way now that a resolution key carries a sequence: the identity is
|
||||
/// taken. Whether a subject is closed is A8's question, not this one's.
|
||||
fn refuse_collisions(docs: &Docs) -> Result<(), MindRefusal> {
|
||||
for staged in &docs.batch {
|
||||
if docs.in_image(staged.kind, &staged.key).is_some() {
|
||||
return Err(match &staged.document {
|
||||
PipelineDocument::Resolution(resolution) => MindRefusal::AlreadyResolved { subject: resolution.subject.id.0.clone() },
|
||||
_ => MindRefusal::IdentityCollision { kind: staged.kind, id: staged.key.clone() },
|
||||
});
|
||||
return Err(MindRefusal::IdentityCollision { kind: staged.kind, id: staged.key.clone() });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -797,7 +878,7 @@ mod tests {
|
|||
assert_ne!(Mind::path_for(root.path(), &slug(INSTANCE)), Mind::path_for(root.path(), &slug(OTHER_INSTANCE)));
|
||||
committed(admit(&mut yggdrasil, vec![instance(INSTANCE), stewardship(INSTANCE, REPO)]));
|
||||
committed(admit(&mut thought_cage, vec![instance(OTHER_INSTANCE)]));
|
||||
let key = format!("{INSTANCE}:stewardship:GameCult_-Epiphany");
|
||||
let key = format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1");
|
||||
assert!(yggdrasil.envelope(K::Stewardship, &key).is_some());
|
||||
assert!(thought_cage.envelope(K::Stewardship, &key).is_none());
|
||||
assert_eq!(yggdrasil.envelopes().len(), 4);
|
||||
|
|
@ -973,16 +1054,25 @@ mod tests {
|
|||
assert_eq!(mind.get(K::Ruling, &id("ruling", "R8")).unwrap(), Some(D::Ruling(ruling("R8"))));
|
||||
}
|
||||
|
||||
/// A8: one resolution of a subject is in force at a time, and the next
|
||||
/// record of that subject is the next sequence. The two rules are
|
||||
/// separate refusals: a second closure at the right sequence is refused
|
||||
/// because the first still stands, and one at a taken sequence is refused
|
||||
/// before the question of standing arises.
|
||||
#[test]
|
||||
fn subject_resolves_at_most_once() {
|
||||
fn a_subject_with_a_resolution_in_force_refuses_another() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")]));
|
||||
let subject = r(K::Question, &id("question", "Q1"));
|
||||
committed(admit(&mut mind, vec![resolution(subject.clone(), withdrawn())]));
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![resolution(subject.clone(), ResolutionOutcome::Withdrawn { reason: "again".into() })])),
|
||||
refusal(admit(&mut mind, vec![resolution_n(subject.clone(), 2, ResolutionOutcome::Withdrawn { reason: "again".into() })])),
|
||||
MindRefusal::AlreadyResolved { subject: id("question", "Q1") }
|
||||
);
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![resolution(subject.clone(), ResolutionOutcome::Withdrawn { reason: "again".into() })])),
|
||||
MindRefusal::ResolutionOutOfSequence { subject: id("question", "Q1"), expected: 2, actual: 1 }
|
||||
);
|
||||
let mut answering = ruling("R1");
|
||||
answering.answers = Some(s(&id("question", "Q1")));
|
||||
answering.choice = Some(l("A"));
|
||||
|
|
@ -1021,7 +1111,7 @@ mod tests {
|
|||
let spec_ref = r(K::CutSpec, &id("cut_spec", "cut-1.r1"));
|
||||
let finding_ref = r(K::Finding, &id("finding", "cut-1.s1.F1"));
|
||||
let follow_up_ref = r(K::FollowUp, &id("follow_up", "FU-1"));
|
||||
let stewardship_ref = r(K::Stewardship, &format!("{INSTANCE}:stewardship:GameCult_-Epiphany"));
|
||||
let stewardship_ref = r(K::Stewardship, &format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1"));
|
||||
let campaign_ref = r(K::Campaign, &id("campaign", "self"));
|
||||
let report_ref = r(K::CutReport, &id("cut_report", "cut-1.h1"));
|
||||
let verdict_ref = r(K::Verdict, &id("verdict", "cut-1.s1"));
|
||||
|
|
@ -1039,7 +1129,7 @@ mod tests {
|
|||
committed(admit(&mut world(), vec![resolution(stewardship_ref.clone(), withdrawn())]));
|
||||
let mut mind = world();
|
||||
committed(admit(&mut mind, vec![resolution(question_ref.clone(), withdrawn())]));
|
||||
let nested = r(K::Resolution, &id("resolution", "question.Q1"));
|
||||
let nested = r(K::Resolution, &id("resolution", "question.Q1.n1"));
|
||||
committed(admit(&mut mind, vec![resolution(nested.clone(), withdrawn())]));
|
||||
|
||||
// Refused, one per kind, and every non-resolvable kind.
|
||||
|
|
@ -1129,10 +1219,10 @@ mod tests {
|
|||
);
|
||||
answering.choice = Some(l("A"));
|
||||
let (receipt_id, writes) = committed(admit(&mut mind, vec![D::Ruling(answering)]));
|
||||
assert_eq!(writes, vec![r(K::Ruling, &id("ruling", "R1")), r(K::Resolution, &id("resolution", "question.Q1"))]);
|
||||
assert_eq!(writes, vec![r(K::Ruling, &id("ruling", "R1")), r(K::Resolution, &id("resolution", "question.Q1.n1"))]);
|
||||
let receipt = mind.receipts().unwrap().into_iter().find(|receipt| receipt.receipt_id == receipt_id).unwrap();
|
||||
assert_eq!(receipt.writes.len(), 2);
|
||||
let Some(D::Resolution(derived)) = mind.get(K::Resolution, &id("resolution", "question.Q1")).unwrap() else { panic!() };
|
||||
let Some(D::Resolution(derived)) = mind.get(K::Resolution, &id("resolution", "question.Q1.n1")).unwrap() else { panic!() };
|
||||
assert_eq!(derived.outcome, ResolutionOutcome::Answered { by: r(K::Ruling, &id("ruling", "R1")) });
|
||||
let mut again = ruling("R2");
|
||||
again.answers = Some(s(&id("question", "Q1")));
|
||||
|
|
@ -1424,7 +1514,7 @@ mod tests {
|
|||
fn a_hand_off_derives_this_minds_side_only() {
|
||||
let key = format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.GameCult_-Epiphany.2026-09-16");
|
||||
let campaign_key = id("campaign", "self");
|
||||
let stewardship_key = format!("{INSTANCE}:stewardship:GameCult_-Epiphany");
|
||||
let stewardship_key = format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1");
|
||||
|
||||
let mut source = seeded();
|
||||
assert_eq!(
|
||||
|
|
@ -1445,7 +1535,7 @@ mod tests {
|
|||
let mut receiving = opened(MemoryStore::new(), OTHER_INSTANCE);
|
||||
committed(admit(&mut receiving, vec![instance(OTHER_INSTANCE)]));
|
||||
let (_, writes) = committed(admit(&mut receiving, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[&campaign_key])]));
|
||||
let assigned = format!("{OTHER_INSTANCE}:stewardship:GameCult_-Epiphany");
|
||||
let assigned = format!("{OTHER_INSTANCE}:stewardship:GameCult_-Epiphany.n1");
|
||||
assert_eq!(writes, vec![r(K::HandOff, &key), r(K::Stewardship, &assigned)]);
|
||||
let Some(D::Stewardship(derived)) = receiving.get(K::Stewardship, &assigned).unwrap() else { panic!() };
|
||||
assert_eq!(derived.note, Line(key.clone()));
|
||||
|
|
@ -1455,9 +1545,12 @@ mod tests {
|
|||
assert_eq!(derived.assigned_on, date());
|
||||
assert_eq!(source.envelope(K::HandOff, &key).map(|e| &e.payload), receiving.envelope(K::HandOff, &key).map(|e| &e.payload));
|
||||
|
||||
// A derived write passes A3-A7 like any other. A batch carrying its own
|
||||
// stewardship of the repo collides with the derived one at A4, as a
|
||||
// typed refusal, not as a duplicate identity the store complains about.
|
||||
// A derived write passes A3-A7 and A8 like any other, and it counts as
|
||||
// a record of its scope. A batch carrying its own assignment of the
|
||||
// repo beside the hand-off is two assignments: the derived one takes
|
||||
// the sequence after the batch's, and the batch's own is then out of
|
||||
// sequence -- a typed refusal, not a duplicate identity the store
|
||||
// complains about.
|
||||
let mut colliding = opened(MemoryStore::new(), OTHER_INSTANCE);
|
||||
committed(admit(&mut colliding, vec![instance(OTHER_INSTANCE)]));
|
||||
assert_eq!(
|
||||
|
|
@ -1465,15 +1558,16 @@ mod tests {
|
|||
hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[]),
|
||||
stewardship(OTHER_INSTANCE, REPO),
|
||||
])),
|
||||
MindRefusal::IdentityCollision { kind: K::Stewardship, id: assigned }
|
||||
MindRefusal::StewardshipOutOfSequence { repo: REPO.into(), expected: 3, actual: 1 }
|
||||
);
|
||||
}
|
||||
|
||||
/// The key of the resolution of a stewardship: the subject's root, then
|
||||
/// its kind and local.
|
||||
/// The key of the first resolution of a stewardship: the subject's root,
|
||||
/// then its kind and local -- the subject's own sequence included -- then
|
||||
/// this resolution's own sequence.
|
||||
fn id_of(stewardship_key: &str) -> String {
|
||||
let (root, rest) = stewardship_key.split_once(':').unwrap();
|
||||
format!("{root}:resolution:{}", rest.replace(':', "."))
|
||||
format!("{root}:resolution:{}.n1", rest.replace(':', "."))
|
||||
}
|
||||
|
||||
/// The second construction site of a receipt, deliberately: a receipt
|
||||
|
|
|
|||
|
|
@ -98,9 +98,17 @@ pub(crate) fn instance(name: &str) -> PipelineDocument {
|
|||
}
|
||||
|
||||
pub(crate) fn stewardship(instance: &str, repo_name: &str) -> PipelineDocument {
|
||||
stewardship_n(instance, repo_name, 1)
|
||||
}
|
||||
|
||||
/// A repo's `n`th assignment to a mind. The sequence is per `(instance,
|
||||
/// repo)` and the leaf keys it last, so `n2` is the record of a repo
|
||||
/// transferred away and taken back.
|
||||
pub(crate) fn stewardship_n(instance: &str, repo_name: &str, sequence: u32) -> PipelineDocument {
|
||||
PipelineDocument::Stewardship(PipelineStewardship {
|
||||
instance: slug(instance),
|
||||
repo: repo(repo_name),
|
||||
sequence,
|
||||
assigned_on: date(),
|
||||
note: "assigned".into(),
|
||||
})
|
||||
|
|
@ -261,8 +269,16 @@ pub(crate) fn follow_up(label: &str, source: PipelineRef) -> PipelineDocument {
|
|||
}
|
||||
|
||||
pub(crate) fn resolution(subject: PipelineRef, outcome: ResolutionOutcome) -> PipelineDocument {
|
||||
resolution_n(subject, 1, outcome)
|
||||
}
|
||||
|
||||
/// A subject's `n`th resolution. The sequence is per subject and the leaf
|
||||
/// keys it last, so a withdrawn closure stays under its subject and the next
|
||||
/// one is nameable.
|
||||
pub(crate) fn resolution_n(subject: PipelineRef, sequence: u32, outcome: ResolutionOutcome) -> PipelineDocument {
|
||||
PipelineDocument::Resolution(PipelineResolution {
|
||||
subject,
|
||||
sequence,
|
||||
outcome,
|
||||
rationale: "Resolved.".into(),
|
||||
resolved_on: date(),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ use serde::{Deserialize, Serialize};
|
|||
/// formats, key identity, foreign type); the rest are the organ's: store
|
||||
/// identity, epoch, ownership, batch shape, references, and every cross-field
|
||||
/// and cross-document rule of admission.
|
||||
///
|
||||
/// The two sequenced kinds refuse in the same pair of shapes: an
|
||||
/// `OutOfSequence` when the writer's `sequence` is not the previous plus one,
|
||||
/// and `AlreadyResolved`/`AlreadyStewarded` when a record of that scope is
|
||||
/// still in force.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum MindRefusal {
|
||||
Document(
|
||||
|
|
@ -26,6 +31,9 @@ pub enum MindRefusal {
|
|||
IdentityCollision { kind: PipelineKind, id: String },
|
||||
MissingReference { kind: PipelineKind, id: String },
|
||||
AlreadyResolved { subject: String },
|
||||
ResolutionOutOfSequence { subject: String, expected: u32, actual: u32 },
|
||||
AlreadyStewarded { repo: String },
|
||||
StewardshipOutOfSequence { repo: String, expected: u32, actual: u32 },
|
||||
IncompatibleResolution { subject_kind: PipelineKind, outcome: String },
|
||||
CitesResolvedDocument { kind: PipelineKind, id: String },
|
||||
EmptySupersession,
|
||||
|
|
|
|||
Loading…
Reference in a new issue