PlatformShared State
Cluster
Revocation Propagation
A revoke recorded on one node reaches its peers through one pull lane with its own budget, its own cursor, and its own guard.
The set next door, the movement here
This is not the signed epoch-root path
RevocationView the portable core reads, not the per-row SQLite store this page writes. That is Revocation Oracle. The two never meet in one process’s data path. A third object carries the word at the Swarm rung, a signed epoch over revoked subjects and revoked task ids whose root hash every continuation token commits to, and it is documented in Revocation Epochs.Lane two, on its own budget
A clustered trust-control node visits each configured peer in turn. Every visit runs two independent pull lanes, and revocations are the second one, deliberately outside the shared round the other four delta streams compete inside. This is shipped and unconditional for any clustered node started with --revocation-db. Inside the cluster boundary the lane is pull-only and driven by the background sync loop: nothing pushes, nothing gossips, and there is no separate revocation protocol.
// Lane 2: revocations replicate on their OWN round budget, INDEPENDENT of the
// shared budget/receipt round, so a sustained budget backlog (shared-round
// exhaustion) or a broken/slow budget-delta endpoint can never starve
// security-critical revocation propagation. It is skipped ONLY when the peer was
// demoted by lane 1 (fail-closed: a peer that violated the wire contract is
// untrusted, so we pull nothing more from it).
if !peer_was_demoted(state, peer_url) {
let mut revocation_round = PullRoundBudget::new();
let _ = route_pull(
state,
peer_url,
sync_peer_revocations(
state,
&client,
peer_url,
&revocation_contract,
&mut revocation_round,
),
&mut delta_records,
);
}The isolation is asserted, not assumed: revocations_are_not_in_the_shared_budget_round compares function pointers against peer_pullers() and fails if anyone ever adds sync_peer_revocations to the shared list. The lane’s own guard has exactly one condition, a peer demoted earlier in the same visit for violating the wire contract, which is a fail-closed refusal to keep reading from a peer that already misbehaved. Four earlier stages of sync_peer can return before the guard is reached at all: a peer marked partitioned, a failed cluster_status, a failed force-snapshot fetch, and a failed authority sync. Independent of the shared round is not independent of the visit.
Read the let _ carefully. The result is discarded. Revocations are the last pull a visit does, so an error here short-circuits nothing, and unless the error was a protocol violation the peer finishes the round with full standing. All three PullError variants can come out of this lane, and the split is the point: Protocol from a page guard demotes the peer, Transient from the transport or the local store leaves its standing alone, and ForceSnapshot routes it to a full resync without calling it misbehavior. The sequence contract raises the third one on a cursor whose stream identity no longer matches, a stream head that has moved behind the local cursor, and an empty page while the cursor still sits below the advertised head. Each of those is a database that was rolled back or replaced, which is a recoverable state rather than a lie.
The parts
| Piece | Defined in | What it is |
|---|---|---|
GET /v1/internal/revocations/delta | cluster/deltas.rs | The serving side. Peer-authenticated, and it answers two contracts chosen by the query’s cursorVersion: the version 4 sequence stream, or the older tuple projection. |
RevocationDeltaQuery | service_types/cluster_budget.rs | Six optional camelCase params: cursorVersion, streamId, afterSeq, afterRevokedAt, afterCapabilityId, limit. |
StoredRevocationView | service_types/cluster_budget.rs | Three fields on the cluster wire: an optional seq, capabilityId, and revokedAt. No reason, no revoker. |
RevocationCursor | service_types/state.rs | Five fields held per peer on PeerSyncState: cursor_version, stream_id, seq, revoked_at, capability_id. The first three carry values only on the sequence contract. |
RevocationPeerContract | cluster/pull_budget.rs | Legacy, or Current carrying a stream id and a head seq. revocation_peer_contract reads it off the peer’s advertised replication heads and picks which puller runs. |
ensure_revocation_page_ascending | cluster/pull_budget.rs | The page guard for the sequence contract. Checks the cursor version and the stream identity, then hands the page to ensure_sequence_revocation_page_ascending. Returns the page head, or a protocol violation. |
ensure_legacy_revocation_page_ascending | cluster/pull_budget.rs | The page guard for the tuple projection. Compares (revoked_at, capability_id) pairwise and refuses any cursor or record carrying a sequence field. |
PullRoundBudget | cluster/pull_budget.rs | 64 pages, 200,000 records, 20 seconds. A fresh instance per lane, per peer, per round. |
upsert_revocation_if_newer | chio-store-sqlite/src/revocation_store.rs | The apply. Forward-only on revoked_at; an equal or older record rolls the transaction back and returns false. That boolean is what the puller counts and what gates the lag observation. |
CAPABILITY_REVOCATION_LAG | chio-metrics-spec/src/runtime.rs | The histogram family. One observation per record whose upsert actually changed a row. |
What the lane pages is not the revoked-capability set. It is a separate append-only log beside it. upsert_revocation_if_newer writes the projection row and then appends to revocation_delta_log, which carries a dense seq per applied revocation, and list_revocations_after_seq_with_head reads that log rather than the projection:
SELECT seq, capability_id, revoked_at
FROM revocation_delta_log
WHERE seq > ?1
ORDER BY seq ASC
LIMIT ?2The same call returns next_seq from revocation_delta_meta as the stream head, so a page and the head it was cut from arrive together. A second singleton table, revocation_stream_meta, holds a stream_id that identifies the serving epoch. A replication source rotates that id at activation, before it advertises status, so a database rolled back to an earlier file cannot reuse sequence numbers under an identity a peer already cached. A cursor is valid only for the identity that issued it, which is why the cursor carries the id alongside the number. list_limit clamps limit into 1 through MAX_LIST_LIMIT (200), and the puller always asks for the ceiling.
The older contract is still served, and a peer that advertises neither a cursor version nor a stream id in its replication heads is RevocationPeerContract::Legacy. Its pages come from the mutable projection, paginated by the composite tuple:
SELECT capability_id, revoked_at
FROM revoked_capabilities
WHERE (
?1 IS NULL
OR revoked_at > ?1
OR (revoked_at = ?1 AND ?2 IS NOT NULL AND capability_id > ?2)
)
ORDER BY revoked_at ASC, capability_id ASC
LIMIT ?3revoked_at is whole Unix seconds, so ties are common and the capability id is what breaks them. The puller always sends both cursor fields or neither, which is the only combination the tie-break clause handles correctly. That tuple is a pagination token for one pass and nothing more: sync_legacy_peer_revocations calls clear_peer_revocation_cursor on the first empty page, so the next round replays the whole projection from genesis and catches a same-second row that sorts lexically before the previous pass head.
The revoke instant is peer-supplied and unvalidated
revoked_at comes off the wire as a bare i64. Nothing range checks it against the receiver’s clock, and upsert_revocation_if_newer stores what it is given. It decides two things on either contract: whether the row applies at all, since the forward-only compare is against the stored instant, and what the lag sample is, since the helper subtracts it from a local clock read and clamps a negative difference at zero. On the tuple contract it decides a third, because it is half the cursor: a peer whose clock runs fast, or one that lies, moves this node’s cursor for it to a future value, after which every genuine row below that value is filtered out of the query and only the full snapshot delivers it. The sequence contract closes that third one, since its cursor is an integer the serving node allocates.One revocation, end to end
crates/platform/chio-control-plane/src/trust_control/authority_handlers.rs:484-531crates/platform/chio-control-plane/src/trust_control/cluster/deltas.rs:675-752at fe56570The write is an ordinary cluster-aware mutation. handle_revoke_capability validates the service token, calls forward_post_to_leader, and only then touches a store, so a revoke that lands on a follower is applied once on the leader (Leader & Failover owns that hop). revoke stamps revoked_at with the current whole second and returns whether the row was new; a repeat revoke of the same id rolls back and returns false. Only a genuinely new revoke observes the lag family, and that sample is not measured against the row it just wrote: the handler passes a fresh unix_timestamp_now() rather than the stored revoked_at, so a local sample is zero by construction, up to a second boundary crossed between the two clock reads. respond_after_leader_visible_write then re-reads is_revoked and answers on every successful revoke, new or repeat; only the observation is gated on newness.
Serving is four steps and no state. handle_internal_revocations_delta runs validate_cluster_peer_auth (see Cluster Peer Auth), opens the store, runs the query above, and maps rows into the two-field view. A node without --revocation-db answers 409 with trust control service requires --revocation-db rather than an empty page, so a misconfigured peer is loud on the wire. It is not loud in the health view: that 409 becomes a transient pull error, which writes the body into the peer’s last_error (visible on the internal status route) and forces the health label back to Healthy. Read the error string, not the label.
The pulling side is a bounded loop, and the order of its steps is the whole safety argument:
fn sync_current_peer_revocations(
state: &TrustServiceState,
client: &TrustControlClient,
peer_url: &str,
store: &SqliteRevocationStore,
stream_id: &str,
advertised_head_seq: u64,
round: &mut PullRoundBudget,
) -> Result<u64, PullError> {
let mut applied = 0u64;
loop {
// Check the shared round budget BEFORE the next blocking fetch so an
// exhausted stream stops without one more peer request.
if round.is_exhausted() {
break;
}
let cursor = peer_revocation_cursor(state, peer_url);
ensure_current_revocation_cursor(cursor.as_ref(), stream_id).map_err(|error| {
PullError::ForceSnapshot(CliError::cli_other_error(format!(
"revocation cursor stream epoch changed; snapshot required: {error}"
)))
})?;
let after_seq = cursor.as_ref().and_then(|value| value.seq).unwrap_or(0);
let response = client.revocation_deltas(&RevocationDeltaQuery {
cursor_version: Some(REVOCATION_SEQUENCE_CURSOR_VERSION),
stream_id: Some(stream_id.to_string()),
after_seq: Some(after_seq),
after_revoked_at: cursor.as_ref().map(|value| value.revoked_at),
after_capability_id: cursor.as_ref().map(|value| value.capability_id.clone()),
limit: Some(MAX_LIST_LIMIT),
})?;
ensure_revocation_cursor_version(response.cursor_version)?;
if response.stream_id.as_deref() != Some(stream_id) {
return Err(PeerProtocolError::RevocationStreamIdentityMismatch.into());
}
let response_head_seq = response
.head_seq
.ok_or(PeerProtocolError::MissingRevocationSequence)?;
if response_head_seq < after_seq {
return Err(PullError::ForceSnapshot(CliError::cli_other_error(
"revocation stream head moved behind the local cursor; snapshot required"
.to_string(),
)));
}
if response.records.is_empty() {
if after_seq < advertised_head_seq || after_seq < response_head_seq {
return Err(PullError::ForceSnapshot(CliError::cli_other_error(
"revocation stream ended below its advertised head; snapshot required"
.to_string(),
)));
}
break;
}
// Stop the round (not demote) when the local per-round pull cap is hit:
// a large well-ordered backlog resumes next sync round.
if round.charge_page(response.records.len() as u64).is_err() {
break;
}
// Version 4 pages advance through a dense append-only revocation log
// bound to one durable stream identity.
// The validator rejects a missing cursor successor, an interior gap, or
// a legacy response before the local cursor can advance past omitted
// revocations.
let page_head = ensure_revocation_page_ascending(
cursor.as_ref(),
response.cursor_version,
response.stream_id.as_deref(),
stream_id,
&response.records,
)?;
if page_head.seq.unwrap_or(0) > response_head_seq {
return Err(PeerProtocolError::IncompleteRevocationStreamContract.into());
}
applied = applied.saturating_add(apply_revocation_page(store, &response.records)?);
update_peer_revocation_cursor(state, peer_url, page_head);
}
Ok(applied)
}The response checks between the fetch and the round charge are where a stale or swapped database is caught. A response whose cursorVersion is not 4 is UnsupportedRevocationCursorVersion; a response whose streamId differs from the one the peer advertised is RevocationStreamIdentityMismatch; a headSeq that has moved behind the local cursor, and an empty page while the cursor still sits below the advertised head, are both PullError::ForceSnapshot rather than a demotion, because a rolled-back peer is not the same thing as a lying one. An empty page at or above the head is caught up and ends the lane without touching the cursor.
The round charge comes before the guard and before any import, so a page that trips the local cap is dropped whole and refetched next round rather than half-applied. Because pages are 200 rows and the page cap is 64, the binding limit for this lane is 12,800 rows per peer per round, or the 20 second wall clock, whichever arrives first; the 200,000 record cap never binds here. The cursor moves exactly once per page, after every row in it is durable, and it moves to the page head the guard returned rather than to anything the peer nominated.
The 20 seconds is a between-fetch check, not a cancellation. Both is_exhausted and charge_page compare against the deadline, but neither can interrupt a request already in flight, and the peer client is built with a CONTROL_HTTP_TIMEOUT of 15 seconds. A lane that starts its last fetch one millisecond inside the deadline can therefore run to roughly 35 seconds. The budget-write quorum timeout is sized for exactly that: it budgets two full PEER_ROUND_WALL_CLOCK_BUDGET rounds plus three HTTP timeouts per preceding peer.
Cursor-anchored and gap-free, not merely ascending
The log the sequence contract pages is dense, and the puller persists the page head. Those two facts have to be checked against each other, because a page that only ascends can still strand rows. A peer at cursor 10 that answers {110, 111} passes a max-advance test, gets imported, and moves the cursor to 111; rows 11 through 109 are then permanently below the cursor and only a full snapshot delivers them. So the guard requires the page to begin at the cursor's successor and to run consecutively from there:
let mut expected = expected_next_seq;
for &seq in seqs {
if seq != expected {
return Err(PeerProtocolError::NonContiguousPage {
expected_seq: expected,
found_seq: seq,
});
}
expected = expected.saturating_add(1);
}
Ok(())The page is checked in the order it arrived, not sorted first. Sorting would let a reordered page through the guard and then import wrong, which surfaces as a retryable store error instead of demoting the peer that sent it. ensure_revocation_page_ascending runs three checks before it reaches that loop, then ensure_sequence_revocation_page_ascending collects the page's sequence numbers, refuses a record that carries none with MissingRevocationSequence, and anchors the check at after_seq + 1. The head it returns is the last record's seq, which is the page maximum by construction because the page is consecutive.
| Test | What it pins |
|---|---|
sequence_revocation_page_must_be_cursor_anchored_and_dense | Four cases against a cursor at seq 5: a dense page returning its last seq as the head with the cursor version stamped on it; a page whose seqs jump over 8 rejected as NonContiguousPage even though it ascends; a page whose first row repeats the cursor rejected as a resend; and a fresh cursor accepting its first row unconditionally. Its records deliberately carry non-monotonic revoked_at values, which pins that this contract orders on seq alone. |
require_contiguous_page_rejects_cursor_jump_and_interior_gap | The checker itself, over an empty page, two reorders, a forward cursor jump, an interior hole, and a repeated seq. |
revocation_page_rejects_mixed_and_unsupported_cursor_shapes | The contract boundary. A response with no cursor version is LegacyRevocationCursorUnsupported whether or not its records carry a seq; a version 4 response with a record missing its seq is MissingRevocationSequence; an unknown version is UnsupportedRevocationCursorVersion. |
upgraded_revocation_puller_rejects_legacy_downgrade | A peer cannot talk an upgraded puller back down, and a cursor stamped with a retired version is refused rather than reinterpreted. |
The tuple projection gets a weaker guard because it can only support a weaker claim. ensure_legacy_revocation_page_ascending walks the page comparing (revoked_at, capability_id) against the previous row and returns NonAdvancingPage the moment one fails to advance, which rejects an interior reorder and a rewind past the cursor. What it cannot reject is an omission: a projection has no density to argue from, so a peer that silently drops a row passes. That is why the legacy puller throws its cursor away at the end of every pass, and why the periodic snapshot rather than incremental convergence is the backstop on that contract. legacy_projection_cursor_is_safe_only_within_a_full_pass pins that shape directly: an ascending same-second page returns its last row as a head carrying no version, no stream id and no seq; a same-second row that sorts lexically before that head is NonAdvancingPage; and clearing the cursor makes the same backfill acceptable on the next genesis replay.
A violation is PullError::Protocol, which demotes the peer to Unhealthy and flags it for a full snapshot. That removes it from this node’s consensus candidate set and from the budget witness set, and it pins the revocation cursor where it was.
The demotion is not sticky. The next sync round opens with cluster_status, and a successful answer runs update_peer_reachable, which puts health straight back to Healthy before the snapshot probe even runs. What actually persists is the force_snapshot flag: budget_write_quorum_commit_view_locked excludes a flagged peer from the witness set regardless of health, and only a completed snapshot clears it. Consensus candidacy, by contrast, returns after one status call.
The two guards print different lines, and one of them is misleading. A sequence violation is peer returned a page that is not cursor-anchored or has a gap: expected next seq X, found Y, and both numbers are real sequence numbers. A tuple violation reuses NonAdvancingPage, whose two fields are named after_seq and page_max_seq after the integer-sequence streams they were written for. On the tuple contract both labels are wrong. after_seq carries the previous row’s revoked_at, which is the cursor only when the first row is the one that failed, and page_max_seq carries the offending row rather than the page maximum. Both are clamped at zero and cast to u64, and the capability id that actually broke the ordering never appears in the message. Grep for peer returned a non-empty page whose max seq, then read the two numbers it prints as adjacent revoke instants rather than as a maximum and a cursor.
Eight records, then a full snapshot
The delta lane is not the steady state on a busy cluster. Records applied across every lane in one visit are folded into delta_records_since_snapshot at the end of it, by finalize_peer_sync_round, and peer_should_force_snapshot returns true when the explicit flag is set or that counter reaches CLUSTER_SNAPSHOT_RECORD_THRESHOLD, which is 8. The next visit to that peer therefore opens with GET /v1/internal/cluster/snapshot, and apply_cluster_snapshot upserts every revocation in the body, sets the revocation cursor to the head the peer advertised, and zeroes the counter. That head survives only on the sequence contract: validate_revocation_snapshot checks the body is strictly tuple ordered, then returns the advertised cursor only when the peer named a version, a stream id, a dense seq, and a head that is present in the body it just sent. A legacy peer, or an empty set, yields None and the cursor is cleared, so the next pass starts at genesis. The counter is what carries the cadence, not the flag: update_peer_success clears the flag at the end of the same round that set it, and the threshold check still fires on the next visit. Note the fold is conditional. finalize_peer_sync_round returns early for a peer that was demoted this round or is already pending a snapshot, so neither the counter nor the ack heads move on those rounds.
That backstop is load-bearing on the tuple contract for exactly one reason: a composite value cursor is not an insertion order. Node C, holding a cursor of (200, cap-x) against node A, will never see a row A imported afterwards from node B that carries revoked_at = 150, because that row sorts below the cursor and the query filters it out. Clock skew produces the same hole more cheaply and more often: every node stamps revoked_at from its own SystemTime, nothing reconciles those clocks, and a revoke written on a lagging node lands below cursors that other nodes already hold. Incremental pulling cannot repair a backfill below the cursor. The full snapshot can, because it carries the whole set with no cursor at all, and every node pulls every configured peer directly rather than through a spanning tree.
The snapshot is a cadence, not a timer, and the cadence is driven by traffic. update_peer_delta_records returns immediately on a zero count, so a peer pair that has gone quiet never advances the counter and never schedules the repairing snapshot. A hole opened below the cursor on an otherwise idle pair persists until eight further records replicate from that peer, or until something else sets force_snapshot: a protocol violation, a budget window the delta stream cannot page, or a partition injected or healed through POST /v1/internal/cluster/partition.
The snapshot is uncapped and the transport is not
collect_revocation_views pages through the entire revoked_capabilities table into one response body with no record cap, unlike the budget stream’s BUDGET_DELTA_MAX_RECORDS. The client reads peer responses through read_capped_json at MAX_PEER_RESPONSE_BYTES, 64 MiB. A revocation set large enough to breach that cap fails the snapshot fetch, which is also the path a peer pinned by a protocol violation heals through. The collection is also not one transaction: each 200-row page is its own deferred read, so a backdated row imported into the serving node between two pages can be missed by that body while still counting toward the cursor the body advertises.Propagation lag is exported as a histogram
Lag is not inferred from logs. It is observed at the two capability revoke paths, computed the same way at both, and exported as a real histogram family.
pub(crate) fn observe_capability_revocation_lag(revoked_at: i64) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs() as i64)
.unwrap_or(0);
let lag_seconds = now.saturating_sub(revoked_at).max(0) as f64;
chio_metrics_spec::runtime::families::CAPABILITY_REVOCATION_LAG
.observe(&["control_plane"], lag_seconds);
}| Field | Value |
|---|---|
| Name | chio_capability_revocation_lag_seconds, histogram |
| Label | One: authority. The only value emitted in-tree is control_plane. |
| Buckets | 1, 5, 15, 30, 60, 120, 300 seconds |
| Producers | handle_revoke_capability on a newly revoked id (zero by construction, since it passes a fresh clock read rather than the stored instant) and apply_revocation_page on a row that changed the projection (real lag) |
| Exposed at | GET /metrics on the trust-control node, bearer-authenticated with the service token |
| Seeded | preregister_known_label_sets registers control_plane at zero when the router is built, so absent_over_time fires on a scrape gap and not on a quiet cluster |
| Alerts | ChioRevocationLagHigh (p1) on p95 above 30s for 10m, and ChioRevocationLagMetricsMissing (p2), both in deploy/prometheus/chio-alert-rules.yml |
Four things follow from that helper that a dashboard will not tell you. Local revokes and propagated ones share one series, so on a cluster where most revokes originate locally the near-zero samples pull p95 down. The clock is whole seconds on both ends, so sub-second propagation is indistinguishable from instantaneous. The observation is conditional: apply_revocation_page skips it whenever upsert_revocation_if_newer returns false, so the node that originated a revoke and then re-fetches its own row from a peer records nothing, and a row that arrives from two peers is sampled once rather than twice. And the snapshot path applies revocations without observing anything, so the moment a peer resyncs, its catch-up is invisible to the service level objective. The alert aggregates sum by (le), so all of that lands in one p95 with no way to separate the populations.
Guarantees and limits
| Status | Claim | Evidence |
|---|---|---|
| Shipped | Revocations replicate on a round budget independent of the budget, receipt, and lineage lane. The lane’s own guard skips only a peer already demoted this visit; an earlier failure in the same visit (partition, status, snapshot, authority sync) still returns before the lane runs. | sync_peer in cluster/deltas.rs; test revocations_are_not_in_the_shared_budget_round |
| Shipped | Propagation never moves a revocation earlier and never unrevokes. upsert_revocation_if_newer rolls back when the stored revoked_at is at or after the incoming one, and no DELETE against revoked_capabilities exists anywhere in the tree. It is not immutable in the other direction: a strictly newer incoming revoked_at overwrites the stored one, so a peer can move a revoke instant forward. | revocation_store.rs |
| Proved by unit test | On the sequence contract a page that does not begin at the cursor’s successor and run consecutively is a protocol violation, including a page that ascends but jumps; an accepted page returns its last seq as the next cursor. On the tuple contract the weaker guarantee holds: a page that is not strictly ascending from the cursor is a violation, and an omission is not detectable at all. | sequence_revocation_page_must_be_cursor_anchored_and_dense and require_contiguous_page_rejects_cursor_jump_and_interior_gap, both in cluster/pull_budget.rs; the tuple half is legacy_projection_cursor_is_safe_only_within_a_full_pass |
| Proved by unit test | A backdated revoke instant advances the lag histogram under authority="control_plane". The test drives observe_capability_revocation_lag directly; no test exercises the handler-to-histogram path. | capability_revoke_observes_revocation_lag; passport_lifecycle_revoke_does_not_observe_capability_revocation_lag pins the negative |
| Covered by an out-of-lane integration test | Two revokes, one posted to the leader and one to a follower, both become visible on the follower through replication alone. Read this as demonstrated, not gated: the only two callers of that scenario carry #[ignore] ("flaky on CI: trust-cluster quorum race; passes locally", and a slow repeat qualification), and no workflow runs the ignored set, so the assertion never executes on a pull request. | The revocation replication wait in run_trust_control_cluster_proving_scenario, chio-cli/tests/trust_cluster.rs. The bound is 120 seconds of polling, so it proves convergence, not a latency figure. |
| Modeled elsewhere | Nothing on this page is model-checked. formal/tla/RevocationPropagation.tla exists, and its safety invariants run on the pull-request job while the liveness property runs in the nightly formal-tla-liveness lane. Its propagation mapping names chio-federation/src/revocation_gossip.rs and the oracle freshness gate. None of its fourteen mirror entries in formal/proof-manifest.toml names sync_peer_revocations, ensure_revocation_page_ascending, or any file under trust_control/cluster/. | formal/proof-manifest.toml, mirror blocks for RevocationPropagation.tla; the module header |
| Limit | On the tuple contract a mid-stream skip is not client-detectable: a projection admits no density argument, so a peer that silently omits a row passes every guard, and the periodic snapshot is the backstop rather than incremental convergence. The sequence contract removes that hole for the delta log by requiring a consecutive page, which is what NonContiguousPage exists to report. | The doc comment on require_forward_progress; require_contiguous_page and its doc comment |
| Limit | A revocation that sorts below a peer’s cursor, whether from a backfill or from clock skew between nodes, is never delivered incrementally from that peer. Reconvergence waits for a snapshot, and the 8-record threshold only advances while records are actually replicating, so a quiet peer pair never schedules one. | list_revocations_after predicate; update_peer_delta_records; peer_should_force_snapshot |
| Limit | A demotion for a protocol violation lasts one round. The next visit’s cluster_status call restores Healthy and consensus candidacy before anything re-validates the peer. Only the force_snapshot flag survives, and it keeps the peer out of the budget witness set alone. | update_peer_reachable at the top of sync_peer; the force_snapshot guard in budget_write_quorum_commit_view_locked |
| Limit | The wire revoked_at is trusted as given. No bound relates it to the receiver’s clock, so a future-dated instant wins the forward-only compare and suppresses every later genuine revoke of that capability, and it makes the lag sample zero. On the tuple contract it also pushes this node’s cursor for that peer forward and strands every genuine row below it until a snapshot. | upsert_revocation_if_newer; both page guards check order, not range |
| Limit | A peer whose revocation endpoint fails keeps full cluster standing. A transport error or a 409 from a peer without --revocation-db is PullError::Transient, which records last_error and forces health back to Healthy; the visit still finalizes, so that peer keeps voting in consensus and witnessing budget writes while replicating no revocations at all. | route_pull, update_peer_sync_error, finalize_peer_sync_round |
| Not claimed | Any bound on how long propagation takes. The applicable guarantee class is leader-local: single-writer local truth and eventual repair, with no globally linearizable control-plane view. The bounded profile has no row for revocation specifically. | docs/standards/CHIO_BOUNDED_OPERATIONAL_PROFILE.md, trust-control write and read rows |
| Unsupported | Un-revocation, and any propagation of why. The wire record carries a sequence number, a capability id and an instant, the table has no reason or revoker column, and nothing deletes from it, so a mistaken revoke is permanent across the cluster. | StoredRevocationView; revoked_capabilities schema |
Next Steps
- Revocation Store · the set this lane writes into, and the durability gate that denies dispatch without one
- Replication & Convergence · the round this lane rides in, its four peer-standing outcomes, and the snapshot backstop in general
- Revocation Oracle · the signed epoch-root path this page excludes, and the freshness window it is gated on
- Cluster Peer Auth · who is allowed to ask for a delta page, and what the digest covers
- Leader & Failover · the forward that decides which node performs the revoke in the first place