feat(store): a record's source can be corrected without touching its words (P5)

Accounting reason: a convention, a decision or an open item carries who
decided it, when and on what authority. When that is recorded wrongly the
honest fix is to say so on the record, not to restate the policy: a policy
revision bumps the convention's revision and reads as a change of the rule,
and a new decision supersedes the old one. A source correction changes only
the authority and says why, so the audit trail shows the words were never
altered.

New event types convention.source_revised, decision.source_revised and
open_item.source_revised {id, source, reason} under the journal feature
source_revisions: an older version would refuse the types, or show the old
source as the record's authority, so it stops at the declaration instead
(rules.md B-27) and books that have not had the owner's upgrade refuse the
write with format_upgrade_pending, journaling nothing. Replay sets the
source and nothing else (the text, the revision and the journal line stay);
the projection updates the rows' existing source columns, so no schema
changed. A correction must be one: other words, the same source, no reason
or an unknown record is refused.

books.set_convention takes reason (the text exactly as it stands),
books.record_decision and books.raise_item take revises with reason. Without
reason the old refusal stands and now says how to correct only the source.

8.0.6: the hand-written lines of the three shapes in
books_written_under_older_rules_still_open; fixture old-books/p5_before,
written by b97234ac (a revised convention, two decisions, two open items, a
draft) through old-books-writer/src/bin/p5_before.rs, opens to that
version's figures, so no figure moved; books_p2_served_cut_over_whole
refuses the correction before consent and takes it after; the fuzzer and the
viewer's journal know the types. The live books refuse the new write until
the owner gives the go-ahead; merging does not need it.

Tests: a_source_only_revision_is_journaled,
a_source_revision_must_change_only_the_source,
source_revisions_wait_for_the_upgrade, source_revisions_is_its_own_feature.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 15:48:27 -06:00
1 parent cf73d0bda5
commit 4236f03744
19 files changed
+1188 -13

No files matched your search

+79
View File
@@ -277,6 +277,30 @@ pub struct NewDecision {
pub projects: Vec<crate::ClassId>,
}
/// Where a decision comes from: who decided, when, and what it rests on.
/// A correction of these three is journaled on its own
/// (`decision.source_revised`), the decision itself unchanged.
#[derive(Clone, Debug, PartialEq)]
pub struct DecisionSource {
/// Who decided (names), at least one.
pub decided_by: Vec<String>,
/// When.
pub decided_on: NaiveDate,
/// The rule or document it rests on, if any.
pub citation: Option<String>,
}
impl NewDecision {
/// The decision's source.
pub fn source(&self) -> DecisionSource {
DecisionSource {
decided_by: self.decided_by.clone(),
decided_on: self.decided_on,
citation: self.citation.clone(),
}
}
}
/// A decision as the journal holds it.
#[derive(Clone, Debug, PartialEq)]
pub struct Decision {
@@ -645,6 +669,39 @@ impl Conventions {
Ok(())
}
/// Correct a convention's source as journaled (`convention.source_revised`):
/// who decided it, when and on what; its text, revision and line stay.
pub fn apply_convention_source(&mut self, id: &str, source: Source) -> Result<(), String> {
let c = self
.conventions
.get_mut(id)
.ok_or_else(|| format!("unknown convention {id:?}"))?;
c.source = source;
Ok(())
}
/// Correct a decision's source as journaled (`decision.source_revised`).
pub fn apply_decision_source(&mut self, id: u64, source: DecisionSource) -> Result<(), String> {
let d = self
.decisions
.get_mut(&id)
.ok_or_else(|| format!("unknown decision {id}"))?;
d.new.decided_by = source.decided_by;
d.new.decided_on = source.decided_on;
d.new.citation = source.citation;
Ok(())
}
/// Correct an open item's source as journaled (`open_item.source_revised`).
pub fn apply_open_item_source(&mut self, id: u64, source: Source) -> Result<(), String> {
let i = self
.open_items
.get_mut(&id)
.ok_or_else(|| format!("unknown open item {id}"))?;
i.new.source = source;
Ok(())
}
/// Record an approval as journaled.
pub fn apply_approval(&mut self, entry: EntryId, a: Approval) {
self.approvals.entry(entry).or_default().push(a);
@@ -800,6 +857,28 @@ pub fn check_decision(d: &NewDecision) -> Result<NewDecision, String> {
})
}
/// Check a decision's corrected source (the same rules as a new
/// decision's own).
pub fn check_decision_source(s: &DecisionSource) -> Result<DecisionSource, String> {
if s.decided_by.is_empty() {
return Err("decided_by names at least one person".into());
}
crate::calendar::check(s.decided_on).map_err(|e| e.to_string())?;
Ok(DecisionSource {
decided_by: s
.decided_by
.iter()
.map(|n| line_text("decided_by", n, MAX_TEXT))
.collect::<Result<_, _>>()?,
decided_on: s.decided_on,
citation: s
.citation
.as_deref()
.map(|c| long_text("citation", c, MAX_LONG_TEXT))
.transpose()?,
})
}
/// Check a new open item's text (INVARIANT 6).
pub fn check_open_item(i: &NewOpenItem) -> Result<NewOpenItem, String> {
i.source.check()?;
+129
View File
@@ -8753,6 +8753,135 @@ impl Ledger {
.map_err(LedgerError::InvalidConvention)
}
/// Check a correction of a convention's source before it is journaled
/// (P5; `convention.source_revised`): the convention is live, `c` says
/// exactly what it says now (title, text, structure; a change of any is
/// a revision, not a correction) and a source that differs from the
/// one it has. Returns the source as it will be journaled, with the
/// reason; changes nothing.
pub fn check_convention_source(
&self,
c: &crate::conventions::NewConvention,
reason: &str,
) -> Result<(crate::settings::Source, String), LedgerError> {
let bad = LedgerError::InvalidConvention;
let c = crate::conventions::check_new(c).map_err(bad)?;
let old = self.conventions.convention(&c.id).ok_or_else(|| {
bad(format!(
"unknown convention {:?}: books.conventions lists them",
c.id
))
})?;
if old.retired.is_some() {
return Err(bad(format!("convention {} is retired", c.id)));
}
if old.kind != c.kind {
return Err(bad(format!(
"convention {} is a {}; its kind can't change",
c.id,
old.kind.key()
)));
}
if old.title != c.title || old.body_md != c.body_md || old.structured != c.structured {
return Err(bad(format!(
"convention {} changes more than its source: revise it without a reason, or give the text it has now",
c.id
)));
}
if old.source == c.source {
return Err(bad(format!(
"convention {} already has that source",
c.id
)));
}
let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT)
.map_err(bad)?;
if reason.is_empty() {
return Err(bad("a source correction needs a reason".into()));
}
Ok((c.source, reason))
}
/// Apply a journaled `convention.source_revised`.
pub fn apply_convention_source(
&mut self,
id: &str,
source: crate::settings::Source,
) -> Result<(), LedgerError> {
self.conventions
.apply_convention_source(id, source)
.map_err(LedgerError::InvalidConvention)
}
/// Check a correction of a decision's source (`decision.source_revised`).
pub fn check_decision_source(
&self,
id: u64,
source: &crate::conventions::DecisionSource,
reason: &str,
) -> Result<(crate::conventions::DecisionSource, String), LedgerError> {
let bad = LedgerError::InvalidConvention;
let old = self
.conventions
.decision(id)
.ok_or_else(|| bad(format!("decision {id} doesn't exist: books.decisions lists them")))?;
let source = crate::conventions::check_decision_source(source).map_err(bad)?;
if old.new.source() == source {
return Err(bad(format!("decision {id} already has that source")));
}
let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT)
.map_err(bad)?;
if reason.is_empty() {
return Err(bad("a source correction needs a reason".into()));
}
Ok((source, reason))
}
/// Apply a journaled `decision.source_revised`.
pub fn apply_decision_source(
&mut self,
id: u64,
source: crate::conventions::DecisionSource,
) -> Result<(), LedgerError> {
self.conventions
.apply_decision_source(id, source)
.map_err(LedgerError::InvalidConvention)
}
/// Check a correction of an open item's source (`open_item.source_revised`).
pub fn check_open_item_source(
&self,
id: u64,
source: &crate::settings::Source,
reason: &str,
) -> Result<(crate::settings::Source, String), LedgerError> {
let bad = LedgerError::InvalidConvention;
source.check().map_err(bad)?;
let old = self.conventions.open_item(id).ok_or_else(|| {
bad(format!("unknown open item {id}: books.open_items lists them"))
})?;
if old.new.source == *source {
return Err(bad(format!("open item {id} already has that source")));
}
let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT)
.map_err(bad)?;
if reason.is_empty() {
return Err(bad("a source correction needs a reason".into()));
}
Ok((source.clone(), reason))
}
/// Apply a journaled `open_item.source_revised`.
pub fn apply_open_item_source(
&mut self,
id: u64,
source: crate::settings::Source,
) -> Result<(), LedgerError> {
self.conventions
.apply_open_item_source(id, source)
.map_err(LedgerError::InvalidConvention)
}
/// Check a retirement before it is journaled: the convention is live,
/// the reason and source are sound.
pub fn check_retire_convention(