transaction/PruneEligibility.cpp¶
PRUNE-01 dry-run eligibility evaluator implementation (D-03). More...
Namespaces¶
| Name |
|---|
| sgns |
Detailed Description¶
PRUNE-01 dry-run eligibility evaluator implementation (D-03).
Date: 2026-10-19
Read-only evaluation over committed state: config parse/validate (GATE-06 policy), the Light-only construction gate (GATE-01), the single allowlist home (D-C7), and the spend-recency window (D-02). Zero writes — see Evaluate() for the enumeration and window arithmetic.
Source code¶
#include "transaction/PruneEligibility.hpp"
#include <set>
#include "account/UTXOManager.hpp"
#include "crdt/globaldb/globaldb.hpp"
#include "storage/database_error.hpp"
#include "transaction/TransactionManager.hpp"
namespace sgns
{
namespace
{
constexpr char kWindowRequiredMessage[] =
"pruning.enabled requires pruning.finality_window > 0; pruning disabled (fail closed)";
template <typename T>
void RecordReason( T &target, PruneReason reason )
{
if ( std::find( target.reasons.begin(), target.reasons.end(), reason ) == target.reasons.end() )
{
target.reasons.push_back( reason );
}
}
bool HasFailureReason( const std::vector<PruneReason> &reasons )
{
return std::any_of( reasons.begin(),
reasons.end(),
[]( PruneReason reason )
{
return reason != PruneReason::SPENDER_PRUNED_WINDOW_SATISFIED &&
reason != PruneReason::BLOCK_ALREADY_ERASED;
} );
}
base::Logger MarkerLogger()
{
static const base::Logger logger = base::createLogger( "PruneEligibility" );
return logger;
}
} // anonymous namespace
PruningConfig PruningConfig::FromJSON( const rapidjson::Value &pruning_object )
{
PruningConfig config;
if ( !pruning_object.IsObject() )
{
// Absent/malformed object: all defaults (enabled stays false).
return config;
}
if ( pruning_object.HasMember( "enabled" ) && pruning_object["enabled"].IsBool() )
{
config.enabled = pruning_object["enabled"].GetBool();
}
if ( pruning_object.HasMember( "dry_run" ) && pruning_object["dry_run"].IsBool() )
{
config.dry_run = pruning_object["dry_run"].GetBool();
}
// WR-03: zero is rejected at READ time — N=0 would make the window
// predicate vacuously true for every recorded spend — so a degenerate
// window is treated exactly like an absent one.
if ( pruning_object.HasMember( "finality_window" ) && pruning_object["finality_window"].IsUint64() &&
pruning_object["finality_window"].GetUint64() > 0 )
{
config.finality_window = pruning_object["finality_window"].GetUint64();
}
// D-04 budget knob: a degenerate 0 is rejected at read time exactly
// like a zero window (WR-03 rule — a 0-K cycle can never drain); the
// member keeps its safe default.
if ( pruning_object.HasMember( "max_erases_per_cycle" ) && pruning_object["max_erases_per_cycle"].IsUint64() &&
pruning_object["max_erases_per_cycle"].GetUint64() > 0 )
{
config.max_erases_per_cycle = pruning_object["max_erases_per_cycle"].GetUint64();
}
// D-C2 settle knob: a degenerate 0 would let a cycle fire during
// initial sync; rejected at read time, default retained.
if ( pruning_object.HasMember( "settle_seconds" ) && pruning_object["settle_seconds"].IsUint64() &&
pruning_object["settle_seconds"].GetUint64() > 0 )
{
config.settle_seconds = pruning_object["settle_seconds"].GetUint64();
}
if ( config.enabled && !config.finality_window.has_value() )
{
// Fail closed (D-02): a positive N is required when enabled; there
// is no default window, so an enabled request without one (or with
// the degenerate 0) disables itself.
config.enabled = false;
config.disabled_reason = std::string( kWindowRequiredMessage );
}
return config;
}
outcome::result<std::shared_ptr<PruneEligibility>> PruneEligibility::New( crdt::GlobalDB &globaldb,
TransactionManager &transaction_manager,
UTXOManager &utxo_manager,
NodeType node_type,
const PruningConfig &config )
{
if ( node_type != NodeType::Light )
{
// GATE-01 role gate: full/archive nodes never erase history. The
// PruningConfig default-disabled is the separate consent gate and
// is deliberately not consulted here.
return outcome::failure( std::errc::operation_not_permitted );
}
return std::shared_ptr<PruneEligibility>(
new PruneEligibility( globaldb, transaction_manager, utxo_manager, config ) );
}
bool PruneEligibility::IsAllowlistedTxKey( const std::string &key ) const
{
// THE one allowlist home (D-C7): exact base + "tx/" startswith over the
// monitored networks only. The trailing slash is load-bearing — the
// slashless form also matches "txfoo"-style siblings (T-17-0401).
for ( auto network_id : transaction_manager_m.GetMonitoredNetworkIDs() )
{
const std::string prefix = TransactionManager::GetBlockChainBase( network_id ) + "tx/";
if ( key.rfind( prefix, 0 ) == 0 )
{
return true;
}
}
return false;
}
std::string PruneEligibility::BuildPruneMarkerKey( const std::string &tx_hash )
{
// Node-local prefix discipline (CRDTWorkJournal shape): the hash keys
// the entry, the tx PATH travels inside the record value.
return std::string( PRUNE_MARKER_PREFIX ) + tx_hash;
}
outcome::result<std::pair<PrunedLookup, std::optional<SGTransaction::PrunedTransactionRecord>>> PruneEligibility::
LookupPrunedEntry( crdt::GlobalDB &db, const std::string &tx_hash )
{
crdt::GlobalDB::Buffer marker_key;
marker_key.put( BuildPruneMarkerKey( tx_hash ) );
auto marker = db.GetRaw( marker_key );
if ( marker.has_error() )
{
if ( marker.error() == storage::DatabaseError::NOT_FOUND )
{
// No marker: this node never pruned the transaction.
return std::make_pair( PrunedLookup::NOT_FOUND, std::nullopt );
}
return outcome::failure( marker.error() );
}
SGTransaction::PrunedTransactionRecord record;
if ( !record.ParseFromArray( marker.value().data(), static_cast<int>( marker.value().size() ) ) )
{
// T-17-0502: a malformed marker is an ERROR, never a silent PRUNED.
MarkerLogger()->error( "Pruned marker for tx {} is malformed", tx_hash );
return outcome::failure( std::errc::bad_message );
}
// WR-04: the record's tx_path must be THE path this hash names (the
// hash is the path's terminal segment). A marker pointing elsewhere —
// or an empty payload whose tx_path is empty — would otherwise answer
// PRUNED for the wrong key (granting SPENDER_PRUNED_WINDOW_SATISFIED)
// or prefix-scan the entire store. Fail closed, never guess.
const std::string &marker_path = record.tx_path();
if ( marker_path.empty() || marker_path.size() < tx_hash.size() ||
marker_path.compare( marker_path.size() - tx_hash.size(), tx_hash.size(), tx_hash ) != 0 )
{
MarkerLogger()->error( "Pruned marker for tx {} carries mismatched path {}", tx_hash, marker_path );
return outcome::failure( std::errc::bad_message );
}
// Value presence through the same CRDT-layer query surface the
// evaluator enumerates (T-17-0503: one resolution path for both reads).
// Exact-key match — a prefix scan alone would also catch longer
// sibling keys sharing the path as a prefix.
bool value_present = false;
if ( auto entries = db.QueryKeyValues( record.tx_path() ); !entries.has_error() )
{
for ( const auto &[key, value] : entries.value() )
{
(void) value; // presence only, the bytes are irrelevant here
auto key_string = db.KeyToString( key );
if ( !key_string.has_error() && key_string.value() == record.tx_path() )
{
value_present = true;
break;
}
}
}
else
{
return outcome::failure( entries.error() );
}
if ( value_present )
{
// T-17-0503: report loudly, never guess which side is right. The
// Phase-18 same-batch write makes this state atomically impossible.
MarkerLogger()->error( "Pruned marker AND value both present for tx {} at {} — invariant violation",
tx_hash,
record.tx_path() );
return std::make_pair( PrunedLookup::VALUE_PRESENT_INVARIANT_VIOLATION, record );
}
return std::make_pair( PrunedLookup::PRUNED, record );
}
std::pair<base::Buffer, base::Buffer> PruneEligibility::BuildPruneMarkerEntry( const std::string &tx_hash,
const std::string &resolved_tx_path )
{
// The production twin of WritePrunedEntryForTest's body: the SAME key
// home (BuildPruneMarkerKey) and record shape, returned as buffers for
// the caller to stage into the erase WriteBatch (value XOR marker in
// one commit) instead of written directly. The record names the
// RESOLVED path the caller's erase targets (WR-02) — never a
// local-network formatting of the hash alone, so a cross-network
// entry is marked with ITS network's path.
SGTransaction::PrunedTransactionRecord record;
record.set_tx_path( resolved_tx_path );
base::Buffer key_buffer;
key_buffer.put( BuildPruneMarkerKey( tx_hash ) );
base::Buffer value_buffer;
value_buffer.put( record.SerializeAsString() );
return { std::move( key_buffer ), std::move( value_buffer ) };
}
outcome::result<void> PruneEligibility::WritePrunedEntryForTest(
crdt::GlobalDB &db,
const std::string &tx_hash,
const SGTransaction::PrunedTransactionRecord &record )
{
// TEST-ONLY (see header): the production write joins Phase 18's erase
// WriteBatch so value XOR marker holds atomically.
crdt::GlobalDB::Buffer key_buffer;
key_buffer.put( BuildPruneMarkerKey( tx_hash ) );
crdt::GlobalDB::Buffer value_buffer;
value_buffer.put( record.SerializeAsString() );
return db.PutRaw( key_buffer, value_buffer );
}
outcome::result<PruneEvaluationResult> PruneEligibility::Evaluate() const
{
if ( !config_m.finality_window.has_value() )
{
// Fail closed: a window without N cannot make anything eligible.
logger_->warn( "Prune evaluation requested without a finality window" );
return outcome::failure( std::errc::invalid_argument );
}
const uint64_t window = config_m.finality_window.value();
auto head_list = globaldb_m.GetCRDTHeadList();
if ( head_list.has_error() )
{
logger_->error( "Unable to read CRDT head list for prune evaluation: {}", head_list.error().message() );
return outcome::failure( head_list.error() );
}
const uint64_t max_height = head_list.value().second;
PruneEvaluationResult result;
for ( auto network_id : transaction_manager_m.GetMonitoredNetworkIDs() )
{
// Trailing slash is load-bearing (Phase-16 lesson): the slashless
// form also matches "txfoo"-style siblings of the tx/ namespace.
const std::string query_path = TransactionManager::GetBlockChainBase( network_id ) + "tx/";
auto transaction_list = globaldb_m.QueryKeyValues( query_path );
if ( transaction_list.has_error() )
{
logger_->error( "Unable to query transactions on {}", query_path );
continue; // never abort the whole scan for one network
}
for ( const auto &[key, value] : transaction_list.value() )
{
auto transaction_key = globaldb_m.KeyToString( key );
if ( transaction_key.has_error() )
{
logger_->error( "Unable to convert a key to string" );
continue;
}
result.candidates.push_back( EvaluateCandidate( transaction_key.value(),
value,
IsAllowlistedTxKey( transaction_key.value() ),
max_height,
window ) );
}
}
// PRUNE-03 block gate: one verdict per DISTINCT CID among the eligible
// candidates (batched commits share one block, so several eligible
// transactions often map to the same CID). Tx eligibility (PRUNE-01)
// and block erasability (PRUNE-03) stay SEPARATE verdicts — a block
// held by a non-eligible sibling never flips a candidate's flag.
std::set<std::string> gated_cids;
for ( const auto &candidate : result.candidates )
{
if ( !candidate.eligible )
{
continue;
}
const auto &candidate_cid = candidate.cid;
if ( !candidate_cid.has_value() )
{
// WR-05: an eligible candidate whose tx->CID map entry could
// not be resolved still gets an explicit block verdict —
// fail closed, never silently dropped from the report.
BlockGateResult ungated;
ungated.cid = "<unresolved for " + candidate.tx_path + ">";
RecordReason( ungated, PruneReason::CID_UNRESOLVED );
logger_->error( "Eligible candidate {} has no resolvable CID — reporting an ungated block verdict",
candidate.tx_path );
result.block_reports.push_back( ungated );
continue;
}
const std::string &cid = candidate_cid.value();
if ( !gated_cids.insert( cid ).second )
{
continue; // this block already gated (shared CID)
}
auto gate = EvaluateDeltaBlockErasable( cid, result );
if ( gate.has_error() )
{
logger_->error( "Block gate failed for cid {}", cid );
continue; // never abort the report for one block
}
result.block_reports.push_back( gate.value() );
}
const auto erasable_blocks = static_cast<size_t>( std::count_if( result.block_reports.begin(),
result.block_reports.end(),
[]( const BlockGateResult &report )
{ return report.erasable; } ) );
logger_->info( "Prune evaluation: {} candidates, {} eligible, {}/{} blocks erasable",
result.candidates.size(),
result.EligibleCount(),
erasable_blocks,
result.block_reports.size() );
return result;
}
outcome::result<BlockGateResult> PruneEligibility::EvaluateDeltaBlockErasable(
const std::string &cid_string,
const PruneEvaluationResult &eligible ) const
{
BlockGateResult report;
report.cid = cid_string;
// The block is the source of truth (D-C3): parse it NOW, keys only,
// elements AND tombstones — "what this node applied" is forbidden as
// an oracle (light nodes store elements they never applied).
auto keys = globaldb_m.GetLocalDeltaKeys( cid_string );
if ( keys.has_error() )
{
// Discriminate ABSENT from corrupt: byte presence (no parse) —
// a corrupt-but-present block still fails closed below.
auto present = globaldb_m.HasLocalBlock( cid_string );
if ( present.has_value() && !present.value() )
{
// The block is ABSENT. Soundness: raw block bytes disappear
// ONLY through the sanctioned erase path (EraseEntry's
// staged CID::toBytes() removal is the sole production
// writer of that deletion — the Phase-16 inventory; the seal
// marker is a different key and SURVIVES, so isResolved
// still guards re-delivery, GATE-03). Absence therefore
// means the sibling group already passed this gate and was
// erased; the verdict COMPLETES a stranded sibling's erase
// (value + map entry + marker — block staging is an
// idempotent no-op on the absent key). It fires ONLY when
// this CID is named by an otherwise-ELIGIBLE candidate: the
// evidence completes an erase the caller already justified.
const bool completes_eligible = std::any_of(
eligible.candidates.begin(),
eligible.candidates.end(),
[&cid_string]( const PruneCandidateResult &candidate )
{ return candidate.eligible && candidate.cid.has_value() && *candidate.cid == cid_string; } );
if ( completes_eligible )
{
RecordReason( report, PruneReason::BLOCK_ALREADY_ERASED );
report.erasable = true;
return report;
}
}
// Corrupt-but-present bytes, an unreadable store, or an absent
// block NOT tied to an eligible candidate: parse failure fails
// closed — not erasable, never a crash.
logger_->error( "Unable to parse delta block {} for the block gate: {}",
cid_string,
keys.error().message() );
RecordReason( report, PruneReason::DESERIALIZE_FAILED );
return report;
}
for ( const auto &key : keys.value() )
{
if ( !IsAllowlistedTxKey( key ) )
{
RecordReason( report, PruneReason::OUT_OF_PREFIX );
continue;
}
const bool in_eligible_set = std::any_of( eligible.candidates.begin(),
eligible.candidates.end(),
[&key]( const PruneCandidateResult &candidate )
{ return candidate.eligible && candidate.tx_path == key; } );
if ( !in_eligible_set )
{
RecordReason( report, PruneReason::NON_ELIGIBLE_SIBLING );
}
}
report.erasable = report.reasons.empty();
return report;
}
PruneCandidateResult PruneEligibility::EvaluateCandidate( const std::string &key,
const base::Buffer &value,
bool allowlisted,
uint64_t max_height,
uint64_t window ) const
{
PruneCandidateResult candidate;
candidate.tx_path = key;
// Defensive allowlist (belt-and-braces): out-of-prefix keys are
// reported with a reason, never silently dropped.
if ( !allowlisted )
{
RecordReason( candidate, PruneReason::OUT_OF_PREFIX );
return candidate;
}
auto transaction = TransactionManager::DeSerializeTransaction( value );
if ( transaction.has_error() || !transaction.value() )
{
// Malformed stored bytes never crash the scan and never become
// eligible (T-17-0405).
RecordReason( candidate, PruneReason::DESERIALIZE_FAILED );
return candidate;
}
if ( auto cid = transaction_manager_m.GetTransactionCID( transaction.value()->GetHash() ); !cid.has_error() )
{
candidate.cid = cid.value();
}
auto outputs = transaction.value()->GetProducedUTXOs();
if ( !outputs.has_value() || outputs->empty() )
{
// No (or zero) produced outputs: no spend evidence can exist —
// vacuous "all outputs spent" must not make a tx eligible.
RecordReason( candidate, PruneReason::NO_OUTPUTS );
return candidate;
}
std::optional<uint64_t> spend_height;
for ( const auto &output : *outputs )
{
if ( !utxo_manager_m.IsOutPointGenuinelyConsumed( output.GetTxID(), output.GetOutputIdx() ) )
{
// Synthesized zero-amount tombstones fail here (D-C2).
RecordReason( candidate, PruneReason::UNSPENT_OUTPUT );
continue;
}
auto spender = utxo_manager_m.GetOutPointSpender( output.GetTxID(), output.GetOutputIdx() );
if ( !spender.has_value() )
{
RecordReason( candidate, PruneReason::SPENDER_NOT_RECORDED );
continue;
}
// Existence BEFORE priority (T-17-0403): resolve the spender's
// tx->CID map entry first — a missing key would read priority 0,
// which satisfies the window vacuously.
auto resolved = transaction_manager_m.ResolveTransactionPathAndCID( spender->toReadableString() );
if ( resolved.has_error() )
{
// Pruned-marker refinement (D-02/B spec point 5): the spender's
// own entries may be gone because the spender was ITSELF pruned
// (marker present, value absent). A pruned spender's own window
// elapsed by construction, so this spend is older still — the
// output's window counts as SATISFIED, with the explicit
// evidence code (never silent). NOT_FOUND or an invariant
// violation keeps SPENDER_UNRESOLVED (fail closed).
auto pruned = LookupPrunedEntry( globaldb_m, spender->toReadableString() );
if ( !pruned.has_error() && pruned.value().first == PrunedLookup::PRUNED )
{
RecordReason( candidate, PruneReason::SPENDER_PRUNED_WINDOW_SATISFIED );
// Effective spend height 0 — the earliest DAG height. The
// max-over-outputs binding is preserved (0 never displaces
// a resolved height), and the window check below still
// requires max_height >= N (fail closed under it).
if ( !spend_height.has_value() )
{
spend_height = 0;
}
continue;
}
RecordReason( candidate, PruneReason::SPENDER_UNRESOLVED );
continue;
}
auto priority = globaldb_m.GetKeyPriority( resolved.value().first );
if ( priority.has_error() )
{
RecordReason( candidate, PruneReason::SPENDER_UNRESOLVED );
continue;
}
// The MOST-RECENT spend binds (Pitfall 3): outputs of one
// transaction can be spent at different times — max, never first.
if ( !spend_height.has_value() || priority.value() > spend_height.value() )
{
spend_height = priority.value();
}
}
candidate.spend_height = spend_height;
if ( HasFailureReason( candidate.reasons ) )
{
// A per-output gate already failed; spend_height stays as the
// diagnostic of whatever partial evidence existed. (The evidence
// code alone does not fail the candidate.)
return candidate;
}
// The one home of the window arithmetic (D-02): spend recency, never
// creation age. An anomalous spend_height above max_height fails too.
const bool window_passed = spend_height.has_value() && max_height >= spend_height.value() &&
( max_height - spend_height.value() ) >= window;
if ( !window_passed )
{
RecordReason( candidate, PruneReason::SPEND_WITHIN_WINDOW );
return candidate;
}
candidate.eligible = true;
return candidate;
}
PruneEligibility::PruneEligibility( crdt::GlobalDB &globaldb,
TransactionManager &transaction_manager,
UTXOManager &utxo_manager,
const PruningConfig &config ) :
globaldb_m( globaldb ),
transaction_manager_m( transaction_manager ),
utxo_manager_m( utxo_manager ),
config_m( config )
{
}
} // namespace sgns
Updated on 2026-10-22 at 19:28:25 +0000