Re-derive what the batch derived, and stop re-raising an earlier record
Two rules Soul's follow-up pass falsified, both in admission. A batch that derives a write could not be replayed. `derive` set every derived sequence to `latest + 1` over an image that already held the first admission's derived record, so the second admission produced a different document, A8 refused it -- `AlreadyResolved` on the source side, and `AlreadyStewarded` on the receiving one -- and A9 never saw the digest it would have recognised. `derive` now reproduces the record it already made when image or batch holds one, found by what the derivation writes into it: a resolution `Answered` by this ruling, a withdrawal whose reason is this hand-off's key, an assignment whose note is. The same document comes back byte for byte, A9 answers with the first receipt, and only a batch that never landed takes `latest + 1`. A withdrawal could re-raise an earlier record over a later one, which is the rationale Q19 A's cap rests on and the cap alone does not carry. Withdrawing a resolution puts its subject back in force, so a repo handed away and handed back could have the hand-off's withdrawal withdrawn and stand under two assignments at once; a supersession withdrawn while the revision that superseded it stood left two revisions in force, which is what the finding rule reads for its invariant vocabulary. `WouldReinstateOverLater` refuses that: the record the withdrawal would reinstate, against anything in force and later in the scope that record is sequenced in -- `(instance, repo)` for a stewardship, the subject for a resolution, the campaign for a target and the cut for a cut spec. Four tests, one per fix and kind: the ruling's resolution and both sides of a hand-off replayed to `AlreadyAdmitted` with the first receipt and no second receipt in the store; the retaken repo and the superseded revision refused, with the later record still the only one in force. Four more tests for rules the code already held and the suite could not tell from weaker ones: a sequence gap refuses on both sequenced kinds rather than only a taken sequence; the latest sequence counts the batch as well as the image; the Q19 cap reads a chain landing in one batch; and the hand-off's second withdrawal of one assignment takes the next sequence, not `1`. 41 tests, 0 warnings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
fcb7fdd061
commit
f566fe6bb3
2 changed files with 237 additions and 4 deletions
|
|
@ -272,6 +272,24 @@ impl Docs {
|
|||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The sequence a derivation takes: the one the record it already derived
|
||||
/// carries, when image or batch holds that record, so an exact replay
|
||||
/// re-derives the same document and A9 answers with the stored receipt;
|
||||
/// otherwise the next. A derivation is recognised by what it writes into
|
||||
/// its record: a resolution by its outcome, an assignment by its note.
|
||||
fn derived_resolution_sequence(&self, subject: &PipelineRef, derived: impl Fn(&ResolutionOutcome) -> bool) -> u32 {
|
||||
self.resolutions()
|
||||
.find(|(_, resolution)| resolution.subject == *subject && derived(&resolution.outcome))
|
||||
.map_or_else(|| self.latest_resolution(subject, None) + 1, |(_, resolution)| resolution.sequence)
|
||||
}
|
||||
|
||||
/// The same for the assignment a hand-off derives, which notes its key.
|
||||
fn derived_stewardship_sequence(&self, mind: &Slug, repo: &OrgRepo, note: &str) -> u32 {
|
||||
self.stewardships()
|
||||
.find(|(_, stewardship)| stewardship.instance == *mind && stewardship.repo == *repo && stewardship.note.0 == note)
|
||||
.map_or_else(|| self.latest_stewardship(mind, repo, None) + 1, |(_, stewardship)| stewardship.sequence)
|
||||
}
|
||||
|
||||
/// The same for a repo's assignments to one mind.
|
||||
fn latest_stewardship(&self, mind: &Slug, repo: &OrgRepo, own: Option<&PipelineStewardship>) -> u32 {
|
||||
self.stewardships()
|
||||
|
|
@ -317,6 +335,14 @@ impl Docs {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// The in-force record that stands later than `base` in the scope `base`
|
||||
/// is sequenced in, if any: what a withdrawal that put `base` back in
|
||||
/// force would leave standing beside it.
|
||||
fn later_in_force(&self, base: &PipelineDocument) -> Option<&str> {
|
||||
let kind = base.kind();
|
||||
self.of_kind(kind).find(|(key, other)| later_than(base, other) && self.in_force(kind, key)).map(|(key, _)| key)
|
||||
}
|
||||
|
||||
/// 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()
|
||||
|
|
@ -462,8 +488,11 @@ fn derive(docs: &Docs, mind: &Slug) -> Vec<PipelineDocument> {
|
|||
D::Ruling(ruling) => {
|
||||
if let Some(question) = &ruling.answers {
|
||||
let subject = PipelineRef { kind: K::Question, id: question.clone() };
|
||||
let sequence = docs.derived_resolution_sequence(&subject, |outcome| {
|
||||
matches!(outcome, ResolutionOutcome::Answered { by } if by.kind == K::Ruling && by.id.0 == staged.key)
|
||||
});
|
||||
derived.push(D::Resolution(PipelineResolution {
|
||||
sequence: docs.latest_resolution(&subject, None) + 1,
|
||||
sequence,
|
||||
subject,
|
||||
outcome: ResolutionOutcome::Answered { by: PipelineRef { kind: K::Ruling, id: Short(staged.key.clone()) } },
|
||||
rationale: ruling.ruling.clone(),
|
||||
|
|
@ -476,8 +505,11 @@ fn derive(docs: &Docs, mind: &Slug) -> Vec<PipelineDocument> {
|
|||
&& 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()) };
|
||||
let sequence = docs.derived_resolution_sequence(&subject, |outcome| {
|
||||
matches!(outcome, ResolutionOutcome::Withdrawn { reason } if reason.0 == staged.key)
|
||||
});
|
||||
derived.push(D::Resolution(PipelineResolution {
|
||||
sequence: docs.latest_resolution(&subject, None) + 1,
|
||||
sequence,
|
||||
subject,
|
||||
outcome: ResolutionOutcome::Withdrawn { reason: Line(staged.key.clone()) },
|
||||
rationale: hand_off.reason.clone(),
|
||||
|
|
@ -487,7 +519,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,
|
||||
sequence: docs.derived_stewardship_sequence(mind, &hand_off.repo, &staged.key),
|
||||
repo: hand_off.repo.clone(),
|
||||
assigned_on: hand_off.handed_on.clone(),
|
||||
note: Line(staged.key.clone()),
|
||||
|
|
@ -735,8 +767,28 @@ fn matrix(subject: PipelineKind, outcome: &ResolutionOutcome) -> bool {
|
|||
fits
|
||||
}
|
||||
|
||||
/// Whether `other` is a later record than `base` in the scope `base` is
|
||||
/// sequenced in: `(instance, repo)` for a stewardship, the subject for a
|
||||
/// resolution, the campaign for a target and the cut for a cut spec. Kinds
|
||||
/// that carry no sequence have no scope and no order.
|
||||
fn later_than(base: &PipelineDocument, other: &PipelineDocument) -> bool {
|
||||
use PipelineDocument as D;
|
||||
match (base, other) {
|
||||
(D::Stewardship(base), D::Stewardship(other)) => {
|
||||
other.instance == base.instance && other.repo == base.repo && other.sequence > base.sequence
|
||||
}
|
||||
(D::Resolution(base), D::Resolution(other)) => other.subject == base.subject && other.sequence > base.sequence,
|
||||
(D::Target(base), D::Target(other)) => other.campaign == base.campaign && other.revision > base.revision,
|
||||
(D::CutSpec(base), D::CutSpec(other)) => {
|
||||
other.campaign == base.campaign && other.cut == base.cut && other.revision > base.revision
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Q19 cap and the reinstatement rule it rests on, 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> {
|
||||
|
|
@ -775,6 +827,22 @@ fn resolution_rule(docs: &Docs, resolution: &PipelineResolution) -> Result<(), M
|
|||
{
|
||||
return Err(incompatible());
|
||||
}
|
||||
// Q19 A's rationale one step out from the cap: a withdrawal never
|
||||
// re-raises an earlier record over a later one. Withdrawing a resolution
|
||||
// puts its subject back in force, so refuse while a later record of that
|
||||
// subject's own scope stands -- a repo's next assignment, a subject's next
|
||||
// resolution, a document's next revision.
|
||||
if matches!(resolution.outcome, ResolutionOutcome::Withdrawn { .. })
|
||||
&& subject_kind == K::Resolution
|
||||
&& let Some(D::Resolution(reinstating)) = docs.find(K::Resolution, subject_id)
|
||||
&& let Some(base) = docs.find(reinstating.subject.kind, &reinstating.subject.id.0)
|
||||
&& let Some(later) = docs.later_in_force(base)
|
||||
{
|
||||
return Err(MindRefusal::WouldReinstateOverLater {
|
||||
subject: reinstating.subject.id.0.clone(),
|
||||
later: later.into(),
|
||||
});
|
||||
}
|
||||
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 });
|
||||
|
|
@ -985,6 +1053,46 @@ mod tests {
|
|||
assert_eq!(receipt_count(&mind), receipts + 1);
|
||||
}
|
||||
|
||||
/// A batch that derives a write replays like any other: the second
|
||||
/// admission re-derives the record the first one landed, sequence and
|
||||
/// all, so A9 recognises the digest and answers with the first receipt
|
||||
/// instead of the derived write colliding with itself.
|
||||
#[test]
|
||||
fn an_exact_replay_re_derives_the_rulings_resolution() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")]));
|
||||
let mut answering = ruling("R1");
|
||||
answering.answers = Some(s(&id("question", "Q1")));
|
||||
answering.choice = Some(l("A"));
|
||||
let (receipt_id, writes) = committed(admit(&mut mind, vec![D::Ruling(answering.clone())]));
|
||||
assert!(writes.contains(&r(K::Resolution, &id("resolution", "question.Q1.n1"))));
|
||||
let receipts = receipt_count(&mind);
|
||||
assert_eq!(
|
||||
admit(&mut mind, vec![D::Ruling(answering)]),
|
||||
PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id }
|
||||
);
|
||||
assert_eq!(receipt_count(&mind), receipts, "a replay writes no second receipt");
|
||||
}
|
||||
|
||||
/// The same on both sides of a hand-off: the source re-derives the
|
||||
/// withdrawal it landed, the receiving mind the assignment.
|
||||
#[test]
|
||||
fn an_exact_replay_re_derives_both_sides_of_a_hand_off() {
|
||||
let away = hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[]);
|
||||
let mut source = seeded();
|
||||
let (receipt_id, _) = committed(admit(&mut source, vec![away.clone()]));
|
||||
let receipts = receipt_count(&source);
|
||||
assert_eq!(admit(&mut source, vec![away.clone()]), PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id });
|
||||
assert_eq!(receipt_count(&source), receipts);
|
||||
|
||||
let mut receiving = opened(MemoryStore::new(), OTHER_INSTANCE);
|
||||
committed(admit(&mut receiving, vec![instance(OTHER_INSTANCE)]));
|
||||
let (receipt_id, _) = committed(admit(&mut receiving, vec![away.clone()]));
|
||||
let receipts = receipt_count(&receiving);
|
||||
assert_eq!(admit(&mut receiving, vec![away]), PipelineAdmissionOutcome::AlreadyAdmitted { receipt_id });
|
||||
assert_eq!(receipt_count(&receiving), receipts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_batch_is_not_answered_from_a_stored_receipt() {
|
||||
let mut mind = seeded();
|
||||
|
|
@ -1134,6 +1242,67 @@ mod tests {
|
|||
]);
|
||||
}
|
||||
|
||||
/// The sequence is exactly the previous plus one: a gap refuses as loudly
|
||||
/// as a taken sequence, on both sequenced kinds, and before the question
|
||||
/// of what still stands arises.
|
||||
#[test]
|
||||
fn a_sequence_gap_refuses_on_both_sequenced_kinds() {
|
||||
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())]));
|
||||
committed(admit(&mut mind, vec![resolution(r(K::Resolution, &id("resolution", "question.Q1.n1")), withdrawn())]));
|
||||
committed(admit(&mut mind, vec![resolution_n(subject.clone(), 2, withdrawn())]));
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![resolution_n(subject, 4, withdrawn())])),
|
||||
MindRefusal::ResolutionOutOfSequence { subject: id("question", "Q1"), expected: 3, actual: 4 }
|
||||
);
|
||||
|
||||
let mut steward = seeded();
|
||||
committed(admit(&mut steward, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[])]));
|
||||
let D::HandOff(mut back) = hand_off(OTHER_INSTANCE, INSTANCE, REPO, &[]) else { panic!() };
|
||||
back.handed_on = epiphany_pipeline::Date("2026-09-17".into());
|
||||
committed(admit(&mut steward, vec![D::HandOff(back)]));
|
||||
assert_eq!(
|
||||
refusal(admit(&mut steward, vec![stewardship_n(INSTANCE, REPO, 4)])),
|
||||
MindRefusal::StewardshipOutOfSequence { repo: REPO.into(), expected: 3, actual: 4 }
|
||||
);
|
||||
}
|
||||
|
||||
/// The sequence counts the batch as well as the image: two records of one
|
||||
/// subject landing together are the second and the third, never two
|
||||
/// firsts, and the first of them is refused for the sequence rather than
|
||||
/// for what the other closes.
|
||||
#[test]
|
||||
fn the_latest_resolution_counts_the_batch() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")]));
|
||||
let subject = r(K::Question, &id("question", "Q1"));
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![
|
||||
resolution_n(subject.clone(), 1, withdrawn()),
|
||||
resolution_n(subject, 2, ResolutionOutcome::Withdrawn { reason: "again".into() }),
|
||||
])),
|
||||
MindRefusal::ResolutionOutOfSequence { subject: id("question", "Q1"), expected: 3, actual: 1 }
|
||||
);
|
||||
}
|
||||
|
||||
/// The Q19 cap reads the batch too: a chain three deep landing in one
|
||||
/// batch is capped exactly as one landing over three admissions.
|
||||
#[test]
|
||||
fn the_cap_reads_a_chain_landing_in_one_batch() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, vec![question("Q1", &["A", "B"], "A")]));
|
||||
committed(admit(&mut mind, vec![resolution(r(K::Question, &id("question", "Q1")), withdrawn())]));
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![
|
||||
resolution(r(K::Resolution, &id("resolution", "question.Q1.n1")), withdrawn()),
|
||||
resolution(r(K::Resolution, &id("resolution", "resolution.question.Q1.n1.n1")), withdrawn()),
|
||||
])),
|
||||
MindRefusal::IncompatibleResolution { subject_kind: K::Resolution, outcome: "Withdrawn".into() }
|
||||
);
|
||||
}
|
||||
|
||||
/// Q18 A and Q20 A at admission: a repo is stewarded by one record at a
|
||||
/// time on a mind, and a repo handed away and handed back is a second
|
||||
/// record rather than a collision or an overwrite.
|
||||
|
|
@ -1194,6 +1363,69 @@ mod tests {
|
|||
assert!(mind.envelope(K::Stewardship, &first).is_some(), "the first assignment is still readable by key");
|
||||
}
|
||||
|
||||
/// Q19 A's rationale, which the cap alone does not carry: a withdrawal
|
||||
/// never re-raises an earlier record over a later one. The repo went away
|
||||
/// and came back, so withdrawing the hand-off's withdrawal would put the
|
||||
/// first assignment back in force beside the second.
|
||||
#[test]
|
||||
fn a_withdrawal_does_not_reinstate_a_stewardship_over_a_later_one() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[])]));
|
||||
let D::HandOff(mut back) = hand_off(OTHER_INSTANCE, INSTANCE, REPO, &[]) else { panic!() };
|
||||
back.handed_on = epiphany_pipeline::Date("2026-09-17".into());
|
||||
committed(admit(&mut mind, vec![D::HandOff(back)]));
|
||||
let first = format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n1");
|
||||
let again = format!("{INSTANCE}:stewardship:GameCult_-Epiphany.n2");
|
||||
let withdrawal = format!("{INSTANCE}:resolution:stewardship.GameCult_-Epiphany.n1.n1");
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &withdrawal), withdrawn())])),
|
||||
MindRefusal::WouldReinstateOverLater { subject: first, later: again.clone() }
|
||||
);
|
||||
let docs = Docs::from_image(mind.envelopes()).unwrap();
|
||||
let in_force = docs.stewardships_of(&slug(INSTANCE), &repo(REPO), None).iter().map(|(key, _)| *key).collect::<Vec<_>>();
|
||||
assert_eq!(in_force, vec![again.as_str()], "one assignment stands, and it is the later one");
|
||||
}
|
||||
|
||||
/// The same rule one kind over: withdrawing a supersession while the
|
||||
/// revision that superseded stands would leave two revisions in force.
|
||||
#[test]
|
||||
fn a_withdrawal_does_not_reinstate_a_revision_over_a_later_one() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, vec![
|
||||
target(2, &[INVARIANT]),
|
||||
resolution(r(K::Target, &id("target", "r1")), superseded(&[r(K::Target, &id("target", "r2"))])),
|
||||
]));
|
||||
assert_eq!(
|
||||
refusal(admit(&mut mind, vec![resolution(r(K::Resolution, &id("resolution", "target.r1.n1")), withdrawn())])),
|
||||
MindRefusal::WouldReinstateOverLater { subject: id("target", "r1"), later: id("target", "r2") }
|
||||
);
|
||||
assert!(docs_of(&mind).in_force(K::Target, &id("target", "r2")));
|
||||
assert!(!docs_of(&mind).in_force(K::Target, &id("target", "r1")));
|
||||
}
|
||||
|
||||
fn docs_of<S: MindStore>(mind: &Mind<S>) -> Docs {
|
||||
Docs::from_image(mind.envelopes()).unwrap()
|
||||
}
|
||||
|
||||
/// The withdrawal a hand-off derives is a record of its subject like any
|
||||
/// other: the second time the repo leaves, it takes the next sequence.
|
||||
#[test]
|
||||
fn a_hand_offs_derived_withdrawal_takes_the_next_sequence() {
|
||||
let mut mind = seeded();
|
||||
committed(admit(&mut mind, vec![hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[])]));
|
||||
// Nothing later stands over the first assignment, so withdrawing the
|
||||
// withdrawal puts it back in force and the repo is this mind's again.
|
||||
let withdrawal = format!("{INSTANCE}:resolution:stewardship.GameCult_-Epiphany.n1.n1");
|
||||
committed(admit(&mut mind, vec![resolution(r(K::Resolution, &withdrawal), withdrawn())]));
|
||||
let D::HandOff(mut again) = hand_off(INSTANCE, OTHER_INSTANCE, REPO, &[]) else { panic!() };
|
||||
again.handed_on = epiphany_pipeline::Date("2026-09-17".into());
|
||||
let (_, writes) = committed(admit(&mut mind, vec![D::HandOff(again)]));
|
||||
assert_eq!(writes, vec![
|
||||
r(K::HandOff, &format!("{INSTANCE}:hand_off:{OTHER_INSTANCE}.GameCult_-Epiphany.2026-09-17")),
|
||||
r(K::Resolution, &format!("{INSTANCE}:resolution:stewardship.GameCult_-Epiphany.n1.n2")),
|
||||
]);
|
||||
}
|
||||
|
||||
/// A seeded mind with one document of every resolvable kind, and the
|
||||
/// non-resolvable ones beside them.
|
||||
fn world() -> Mind<MemoryStore> {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ pub enum MindRefusal {
|
|||
AlreadyStewarded { repo: String },
|
||||
StewardshipOutOfSequence { repo: String, expected: u32, actual: u32 },
|
||||
IncompatibleResolution { subject_kind: PipelineKind, outcome: String },
|
||||
WouldReinstateOverLater { subject: String, later: String },
|
||||
CitesResolvedDocument { kind: PipelineKind, id: String },
|
||||
EmptySupersession,
|
||||
UnknownSupersessor { id: String },
|
||||
|
|
|
|||
Loading…
Reference in a new issue