Skip to content
5 changes: 3 additions & 2 deletions contracts/sysio.epoch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ Operator registration, collateral and status live on `sysio.opreg`; the outpost

1. Emissions readiness gate (block-and-retry via `blocklog`, never a throw).
2. Delivery audit of the expiring group for every active outpost: every operator that delivered a
non-canonical envelope is slashed through `sysio.chalg::slashop`; each member's delivery result is
recorded with `sysio.opreg::recorddel`, followed by `sysio.opreg::termcheck`.
non-canonical envelope is slashed through `sysio.chalg::slashop`; then each member gets one
`sysio.opreg::recorddel` (delivered only if it reached every active outpost), followed by
`sysio.opreg::termcheck`.
3. Bump the epoch index, drop the expired group from the front of the window and append a new tail
group drawn from `ACTIVE` batch operators that are not already resident (non-bootstrapped first).
The resident groups stay disjoint, so an operator serves at most once every `batch_op_groups`
Expand Down
6 changes: 3 additions & 3 deletions contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,9 @@ namespace sysio {
static constexpr uint32_t MAX_BATCH_OP_GROUPS = 255;

/// Upper bound on `operators_per_epoch` (the size of one schedule group).
/// Every member of the expiring group costs two inline actions per active
/// outpost in `advance` (opreg::recorddel + opreg::termcheck) plus an inline
/// transfer in sysio.system::payepoch, so group size directly scales the
/// Every member of the expiring group costs two inline actions in
/// `advance` (opreg::recorddel + opreg::termcheck) plus a payclaims
/// credit in sysio.system::payepoch, so group size directly scales the
/// epoch-boundary transaction; an absurd value (which the product equality
/// alone cannot reject -- UINT32_MAX groups of one is internally consistent)
/// would abort `advance` on its vector reserves and halt epoch advancement
Expand Down
62 changes: 37 additions & 25 deletions contracts/sysio.epoch/src/sysio.epoch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -415,9 +415,12 @@ void epoch::advance() {
// For each (outpost × member of the expiring group):
// - scan `msgch::envelopes` (`byoutepoch` index) for any row matching
// (chain_code, current_epoch_index, batch_op_name == member)
// - collect the delivery result and every non-canonical deliverer
// - slash all non-canonical deliverers before delivery accounting can
// terminate them, then record the result and run `termcheck`
// - count the outposts the member delivered to and collect every
// non-canonical deliverer
// Then slash the non-canonical deliverers before delivery accounting can
// terminate them, and record one result per member, delivered only if it
// reached every active outpost: `sysio.opreg`'s miss thresholds count duty
// epochs, not outposts.
//
// The outpost set is sourced via a cross-contract read of
// `sysio.chains::chains` (no local mirror) filtered to
Expand All @@ -444,18 +447,23 @@ void epoch::advance() {
// operator, which would abort advance and stall the chain).
std::vector<name> to_slash;

/// A delivery result retained until non-canonical offenders have been
/// slashed. `recorddel` remains an audit record even for a newly
/// One member's delivery result for the expiring epoch, accumulated across
/// the active outposts and retained until non-canonical offenders have
/// been slashed. `recorddel` remains an audit record even for a newly
/// slashed operator, while `termcheck` safely skips non-ACTIVE rows.
struct delivery_observation {
name member;
bool did_deliver;
uint32_t outposts_delivered = 0; ///< Active outposts this member delivered to.
};
std::vector<delivery_observation> observations;
observations.reserve(expiring_group.size());
for (const auto& member : expiring_group) observations.push_back({member});
uint32_t active_outposts = 0;

sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT);
for (auto op_it = chains_tbl.begin(); op_it != chains_tbl.end(); ++op_it) {
if (!is_active_outpost(*op_it)) continue;
++active_outposts;

const uint64_t chain_code = op_it->code.value;
const uint128_t composite =
Expand Down Expand Up @@ -494,27 +502,27 @@ void epoch::advance() {
}
}

for (const auto& member : expiring_group) {
for (auto& observation : observations) {
bool did_deliver = false;
checksum256 member_checksum{};
for (size_t i = 0; i < delivered.size(); ++i) {
if (delivered[i] == member) {
if (delivered[i] == observation.member) {
did_deliver = true;
member_checksum = delivered_checksums[i];
break;
}
}
observations.push_back({member, did_deliver});
if (did_deliver) ++observation.outposts_delivered;

// Single slash path (dispute-vote design, per-operator outcome table): a delivered
// NON-canonical checksum is a fault -> slash. Silence (no delivery) is never slashed; it
// stays on the recorddel/termcheck miss ladder below. Collect here; flush once below.
if (did_deliver && have_winner && member_checksum != winner) {
bool queued = false;
for (const auto& s : to_slash) {
if (s == member) { queued = true; break; }
if (s == observation.member) { queued = true; break; }
}
if (!queued) to_slash.push_back(member);
if (!queued) to_slash.push_back(observation.member);
}
}
}
Expand Down Expand Up @@ -545,20 +553,24 @@ void epoch::advance() {

// Preserve delivery history and ordinary termination accounting. Inline
// slashing completes before these actions, and all mutations complete
// before finishadv reads the registry for outbound attestations.
for (const auto& observation : observations) {
action(
permission_level{get_self(), "owner"_n},
OPREG_ACCOUNT,
opreg_actions::RECORD_DELIVERY,
std::make_tuple(observation.member, state.current_epoch_index, observation.did_deliver)
).send();
action(
permission_level{get_self(), "owner"_n},
OPREG_ACCOUNT,
opreg_actions::TERMINATION_CHECK,
std::make_tuple(observation.member)
).send();
// before finishadv reads the registry for outbound attestations. With no
// active outpost nothing was due, so nothing is recorded.
if (active_outposts > 0) {
Comment thread
heifner marked this conversation as resolved.
Resolved
for (const auto& observation : observations) {
const bool delivered_everywhere = observation.outposts_delivered == active_outposts;
action(
permission_level{get_self(), "owner"_n},
OPREG_ACCOUNT,
opreg_actions::RECORD_DELIVERY,
std::make_tuple(observation.member, state.current_epoch_index, delivered_everywhere)
).send();
action(
permission_level{get_self(), "owner"_n},
OPREG_ACCOUNT,
opreg_actions::TERMINATION_CHECK,
std::make_tuple(observation.member)
).send();
}
}

// The envelope rows read above are left in place: `sysio.msgch::deliver`
Expand Down
Binary file modified contracts/sysio.epoch/sysio.epoch.wasm
Binary file not shown.
45 changes: 24 additions & 21 deletions contracts/sysio.opreg/include/sysio.opreg/sysio.opreg.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,16 @@ namespace sysio {
"production default percent-miss threshold must lie inside the accepted bounds");

/// Minimum accepted `terminate_window_ms` for a given consecutive-miss
/// threshold and epoch schedule. Delivery records accrue only on an
/// operator's DUTY epochs: `sysio.epoch::advance` runs `recorddel` for
/// the expiring group alone, and the schedule is a sliding window of
/// `batch_op_groups` groups, so a resident operator's duty interval is
/// `batch_op_groups` epochs. The rolling window must span the full
/// terminating run -- `consecutive_misses` duty-epoch records -- plus
/// one duty interval of boundary slack, or records age out (and are
/// pruned) before `termcheck` can observe the run, leaving the
/// consecutive rail structurally vacuous (SEC-28 residual). Operators
/// threshold and epoch schedule. Delivery records accrue one per DUTY
/// epoch: `sysio.epoch::advance` records each member of the expiring
/// group once, combined across the active outposts. The schedule is a
/// sliding window of `batch_op_groups` groups, so a resident operator's
/// duty interval is `batch_op_groups` epochs. The rolling window must
/// span the full terminating run (`consecutive_misses + 1` records,
/// `consecutive_misses` duty intervals end to end) plus one duty interval
/// of boundary slack, or records age out (and are pruned) before
/// `termcheck` can observe the run, leaving the consecutive rail
/// structurally vacuous. Operators
/// benched by a surplus roster accrue no records while benched, so no
/// finite window observes them; the bound governs resident operators,
/// whose duty interval the sliding schedule pins at `batch_op_groups`
Expand Down Expand Up @@ -250,18 +251,19 @@ namespace sysio {
[[sysio::action]]
void slash(name account, std::string reason);

/// Record per-batch-op delivery hit/miss for the rolling 24h buffer.
/// Called inline from `sysio.epoch::advance` after each delivery cycle.
/// Each write also sweeps up to `MAX_DELLOG_PRUNE_PER_WRITE` rows that
/// have aged out of the rolling termination window, keeping the buffer
/// bounded without a dedicated crank.
/// Record a batch operator's result for one duty epoch in the rolling
/// buffer. Called inline from `sysio.epoch::advance` once per
/// expiring-group member; `delivered` only if it reached every active
/// outpost. Each write also prunes up to `MAX_DELLOG_PRUNE_PER_WRITE` rows
/// that have aged out of the window, so no dedicated crank is needed.
[[sysio::action]]
void recorddel(name account, uint32_t epoch, bool delivered);

/// Evaluate the rolling 24h delivery buffer for an operator. If it
/// breaches the threshold (>3 consecutive misses OR >5% missed in
/// the trailing 24h), inline `terminate(...)`. Called by
/// `sysio.epoch::advance` after every `recorddel`.
/// Terminate the operator inline when its rolling buffer holds more than
/// `terminate_max_consecutive_misses` consecutive misses, or a
/// whole-percent miss rate (rounded down) above
/// `terminate_max_pct_misses_24h`. Both rails count duty epochs. Called
/// by `sysio.epoch::advance` after every `recorddel`.
[[sysio::action]]
void termcheck(name account);

Expand Down Expand Up @@ -471,9 +473,10 @@ namespace sysio {
sysio::const_mem_fun<withdraw_request, uint64_t, &withdraw_request::by_account>>
>;

/// Per-batch-op rolling delivery buffer. One row per (operator, epoch)
/// recording whether the operator delivered on schedule. Rows older
/// than `TERMINATE_WINDOW_MS` are discarded by `prune` / on-write.
/// Per-batch-op rolling delivery buffer. One row per (operator, duty
/// epoch), `delivered` only if the operator delivered to every active
/// outpost. Rows older than `terminate_window_ms` are discarded by
/// `prune` / on-write.
struct delivery_key {
uint64_t log_id;
uint64_t primary_key() const { return log_id; }
Expand Down
Loading
Loading