Skip to content

fix(payment): require an explicit tolerance to enforce the price floor - #196

Closed
grumbach wants to merge 1 commit into
WithAutonomi:mainfrom
grumbach:price-floor-enforce-requires-explicit-tolerance
Closed

fix(payment): require an explicit tolerance to enforce the price floor#196
grumbach wants to merge 1 commit into
WithAutonomi:mainfrom
grumbach:price-floor-enforce-requires-explicit-tolerance

Conversation

@grumbach

@grumbach grumbach commented Aug 6, 2026

Copy link
Copy Markdown
Member

Linear issue

Risk tier

  • T0 — docs / tooling / CI / pure UX-output. Repo CI only.
  • T1 — client-only, no network-facing behavior change. CI + prod compat smoke.
  • T2 — node/client logic with behavioral surface, no protocol/format/economics change. Dev testnet + ADR.
  • T3 — protocol / storage format / payments / routing. T2 evidence + adversarial testing.

T2, and worth stating why it is not T3 given the file. This changes no pricing, no
quote, no settlement arithmetic and nothing on the wire. It changes one startup
decision: whether an operator's enforcement opt-in is honoured. The only behaviour
that can differ is on a node that sets ANT_PRICE_FLOOR_ENFORCE=1 without a
tolerance, and that node now stays in shadow mode instead of enforcing.

Not T1, because a node could in principle be enforcing today and stop.
No node is. The measurement in #193 covers 912 of 913 production instances
over 2026-07-29..08-04, all shadow.

Compatibility

  • Wire: none.
  • Storage: none.
  • API: none. PriceFloorConfig keeps its shape and its Default.
  • Operational: ANT_PRICE_FLOOR_ENFORCE=1 alone no longer enforces.
    ANT_PRICE_FLOOR_TOLERANCE_PERCENT must also be set to 0..=100. A node that
    sets only the first stays in shadow mode and logs an error saying so.

Semver impact

  • breaking
  • feature
  • fix

No API change; a defect in how an opt-in was interpreted.

Test evidence

  • cfd clean: clippy -D warnings across all targets and features, fmt,
    cargo doc --deny=warnings.
  • --no-default-features clippy holds at the 13-warning baseline main
    already carries. The first draft of this change added a 14th
    (match arms that become identical once the log macros compile out); it was
    restructured to state the diagnostic as a value instead.
  • 919 lib tests, including the extended environment test.

Verified to fail without the change. The production expression was reverted
to the previous semantics and the test observed to break on
enforcement must require an explicitly specified tolerance, not inherit a default, then restored.

The new cases live inside price_floor_from_env_fails_closed_on_invalid_tolerance
rather than in a sibling test. That test already declares it owns the
PRICE_FLOOR_* variables; a sibling setting them raced its cleanup, which is how
the first attempt failed, on the setup assertion rather than the assertion under
test.

New dependency

None.

ADR

https://github.com/WithAutonomi/ant-node/blob/main/docs/adr/ADR-0006-receiver-side-revenue-floor.md

No decision changes. ADR-0006 already records that enforcement is a deliberate
per-node opt-in and that the floor is not safe to enforce yet; this makes the
opt-in behave the way the ADR describes. Not amended, because nothing it decided
moved.

Mitigation / rollback

Revert the commit. No stored data, no migration, no wire effect. An operator who
genuinely wants enforcement sets the tolerance explicitly, which is the
documented path either way.


Why

Found while reviewing #193's own production-safety claim ("ships shadow-only, not
safe to enforce") against the code that shipped. The shadow-only claim holds. This
is about what happens the moment someone acts on the opt-in it documents.

ANT_PRICE_FLOOR_ENFORCE=1 was sufficient by itself, and the tolerance fell back
to a compiled-in default. Two problems with that, one general and one specific to
where this code has been:

  1. The tolerance is the policy. It decides which already-settled,
    unrefundable payments get refused. feat(payment): price the revenue floor against the close-group median #193 measured the previous floor firing on
    0.377% of honest stores at production scale. A default is the wrong thing to
    supply silently for the one variable that decides who is rejected.

  2. Its meaning already changed once. The floor used to be priced against the
    receiver's own commitment-bound price and is now priced against the
    close-group median, so the same percentage does not mean the same thing before
    and after feat(payment): price the revenue floor against the close-group median #193. A node carrying an enforcement opt-in across that upgrade
    would silently start enforcing a different rule at a percentage nobody picked
    for it.

The function's own comment already said it should "never enforce a tolerance the
operator did not actually specify". Two of the three unusable states were exempt
from that rule: unset, and set-but-not-readable-as-Unicode (std::env::var
returns NotUnicode there, and reading it through .ok() collapses it onto
NotPresent, so it looked absent and took the default).

One thing I want to be precise about, because the tidier claim is wrong.
Requiring a valid tolerance already covers the non-Unicode case, since an
unreadable value cannot parse. So separating NotUnicode from NotPresent is
not a second safety fix here. Its value today is the operator-facing message:
telling someone their tolerance is unset, when they did set it to something the
process cannot read, sends them to the wrong fix. I checked this by reverting
each half separately rather than assuming.

Shadow mode is deliberately untouched and still evaluates at the default
tolerance. Producing telemetry is its whole purpose, and it is what the entire
fleet runs.

Not in scope

The review also surfaced two things that are not fixed here, because neither
is a small fix:

  • Gossiped commitments carry no generation counter, so a peer can replay an older
    signed high-count commitment and ingest refreshes it as current for the
    answerability TTL. That compounds the roughly-4-of-15 manipulation bound
    ADR-0006 and feat(payment): price the revenue floor against the close-group median #193 already document.
  • The "at most 19 neighbours" ceiling in the sample is the normal case rather than
    a guarantee: the upstream K-closest helper appends self and truncates to K, so
    self is not guaranteed to survive. Production gossip never caches self, so this
    is unreached today.

Both matter before anyone enables enforcement, and neither belongs in a change
this size.

Setting ANT_PRICE_FLOOR_ENFORCE=1 was enough on its own to enforce the
receiver-side price floor, with the tolerance falling back to a compiled-in
default when the operator had not set one. The tolerance is not a formatting
detail: it decides which already-settled, unrefundable payments a node refuses,
and production measurement of the previous floor put that at 0.377% of honest
stores. Enforcing at a percentage nobody chose is therefore the wrong default
for the one variable that decides who gets rejected.

Its meaning also depends on what the floor is priced against, and that reference
has already changed once, from the receiver's own commitment-bound price to the
close-group median. A node still carrying an enforcement opt-in from before that
change would silently resume enforcing the new rule at a default it never
selected, which is not a decision an upgrade should make on an operator's
behalf.

Enforcement now requires ANT_PRICE_FLOOR_TOLERANCE_PERCENT to name a valid
percentage. Anything else, absent or unparseable or out of range or not readable
as Unicode, fails closed to shadow mode and logs an error naming which of those
states it is in, because the fixes differ. Shadow mode is unchanged: it still
evaluates at the default tolerance, since producing telemetry is its entire
purpose, and it is what every node runs today.

This makes the function honour the rule its own comment already stated, that it
should never enforce a tolerance the operator did not actually specify. Two of
the three unusable states were previously exempt from it.

The diagnostic separates an unset variable from one that is set but unreadable.
That distinction does not change the enforcement decision here, since neither
parses to a valid percentage, so it is stated as an operator-facing message
rather than as a second safety property.

Extends the existing environment test, which owns these variables, rather than
adding a sibling that would race its cleanup. The new cases were confirmed to
fail against the previous behaviour.
@grumbach grumbach closed this Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant