///|
/// A fact used by a caller's decision at two explicit instants. This captures
/// fact evidence, not the application's full rule or computed business result.
pub struct DecisionEvidence {
id : String
key : String
valid_at : Int64
known_at : Int64
source : Revision?
}
///|
pub fn DecisionEvidence::id(self : DecisionEvidence) -> String {
self.id
}
///|
pub fn DecisionEvidence::key(self : DecisionEvidence) -> String {
self.key
}
///|
pub fn DecisionEvidence::valid_at(self : DecisionEvidence) -> Int64 {
self.valid_at
}
///|
pub fn DecisionEvidence::known_at(self : DecisionEvidence) -> Int64 {
self.known_at
}
///|
pub fn DecisionEvidence::source(self : DecisionEvidence) -> Revision? {
self.source
}
///|
pub fn DecisionEvidence::value(self : DecisionEvidence) -> String? {
observed_value(self.source)
}
///|
pub fn Ledger::capture_decision(
self : Ledger,
id : String,
key : String,
valid_at : Int64,
known_at : Int64,
) -> DecisionEvidence raise TemporalError {
if id.is_empty() {
raise InvalidDecision("decision ID must not be empty")
}
if key.is_empty() {
raise EmptyKey
}
{
id,
key,
valid_at,
known_at,
source: self.observe(key, valid_at, known_at),
}
}
///|
/// Review evidence including both versions, even if neither value changed.
pub struct DecisionAudit {
decision : DecisionEvidence
review_known : Int64
current : Revision?
}
///|
pub fn DecisionAudit::decision(self : DecisionAudit) -> DecisionEvidence {
self.decision
}
///|
pub fn DecisionAudit::review_known(self : DecisionAudit) -> Int64 {
self.review_known
}
///|
pub fn DecisionAudit::current(self : DecisionAudit) -> Revision? {
self.current
}
///|
/// A gap and a withdrawal differ, despite both having no effective value.
pub fn DecisionAudit::needs_recompute(self : DecisionAudit) -> Bool {
match (self.decision.source, self.current) {
(None, None) => false
(Some(before), Some(after)) => before.value != after.value
_ => true
}
}
///|
/// Identical values with a new winning claim still change provenance.
pub fn DecisionAudit::provenance_changed(self : DecisionAudit) -> Bool {
!same_source(self.decision.source, self.current)
}
///|
fn same_decision_source(left : Revision?, right : Revision?) -> Bool {
match (left, right) {
(None, None) => true
(Some(a), Some(b)) => a.sequence == b.sequence && same_revision_claim(a, b)
_ => false
}
}
///|
/// Preserve request order, require unique decision IDs and reject evidence
/// whose original winning claim disagrees with this ledger. Review cannot
/// precede capture. This checks queried claims, not the entire archive lineage.
pub fn Ledger::audit_decisions(
self : Ledger,
decisions : Array[DecisionEvidence],
review_known : Int64,
max_decisions~ : Int32 = 4096,
) -> Array[DecisionAudit] raise TemporalError {
if max_decisions <= 0 || decisions.length() > max_decisions {
raise TooManyDecisions(maximum=max_decisions)
}
let ids : Map[String, Bool] = Map([])
for decision in decisions {
if ids.contains(decision.id) {
raise InvalidDecision("duplicate decision ID: " + decision.id)
}
ids[decision.id] = true
if review_known < decision.known_at {
raise InvalidDecision("review knowledge time precedes capture")
}
let original = self.observe(
decision.key,
decision.valid_at,
decision.known_at,
)
if !same_decision_source(original, decision.source) {
raise InvalidDecision("captured fact disagrees with this ledger")
}
}
decisions.map(fn(decision) {
{
decision,
review_known,
current: self.observe(decision.key, decision.valid_at, review_known),
}
})
}
///|
pub fn DecisionAudit::to_json(self : DecisionAudit) -> Json {
let fields : Map[String, Json] = Map([])
fields["schemaVersion"] = Json::number(1.0)
fields["decisionId"] = Json::string(self.decision.id)
fields["key"] = Json::string(self.decision.key)
fields["validAt"] = Json::string(format_instant(self.decision.valid_at))
fields["capturedKnown"] = Json::string(format_instant(self.decision.known_at))
fields["reviewKnown"] = Json::string(format_instant(self.review_known))
fields["capturedSource"] = revision_evidence_json(self.decision.source)
fields["currentSource"] = revision_evidence_json(self.current)
fields["needsRecompute"] = Json(self.needs_recompute())
fields["provenanceChanged"] = Json(self.provenance_changed())
Json::object(fields)
}