Skip to content

fix(bots): normalize the position join key across log events - #189

Open
haydenshively wants to merge 2 commits into
fix/send-revert-classificationfrom
fix/normalize-position-join-key
Open

fix(bots): normalize the position join key across log events#189
haydenshively wants to merge 2 commits into
fix/send-revert-classificationfrom
fix/normalize-position-join-key

Conversation

@haydenshively

@haydenshively haydenshively commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Closes BOTS-90. Top of the stack — this PR's own diff is fix/send-revert-classification...fix/normalize-position-join-key.

Why

The correlation id joining a position across pipeline stages differed in field name, shape and letter case, so a naive GROUP BY split one position into rows that looked like different positions. Grouping the 2026-08-28 maturity by the raw composite returned 26 rows for 13 positions — read without care, that says the bot planned 13 positions it never quoted and quoted 13 it never planned.

stage field value
plan.built, simulate.* marketId + borrower, separate borrower checksummed
select.ok, quote.* single id marketId:borrower lowercased
tx.* single label the same lowercased composite

The canonical key already existed — lensKey() — and was already the value behind every label/id. Only the field name and the checksummed pair diverged.

What

  • A full audit of all 101 logger.* sites across both liquidators, @repo/swaps and @repo/bot-kit, classifying each as position-scoped or not. That table is in the commit history and the READMEs; the issue existed because nobody had one.
  • id on every position-scoped eventplan.built, plan.skipped, cooldown.skip, config.no_swap_path, simulate.*, quote.unprofitable, send.revert_streak, preselect.skipped, route.unresolved, quote.excluded_collateral, select.cold_default, and the @repo/swaps quote events. marketId/borrower stay on plan.built as human-readable extras.
  • A candidate discriminator threaded through QuoteRequestfeat(midnight-liquidation): support loan-as-collateral markets #184 means one position yields several (slot, mode) candidates sharing one id, so select.ok rows were otherwise indistinguishable. id stays the per-position join key; (id, collateralIndex, postMaturityMode) identifies a candidate. Correlation-only: nothing in @repo/swaps branches on it.
  • The queue's emitted field renamed labelid across 16 sites. SubmitArgs.label, Pending.label, the settledAt keys and inflightLabels() keep the name — they are behavioral, and changing the value would break inflightLabels().has(label) and produce duplicate nonce-consuming sends. Only the log key changes; the invariant is documented at SubmitArgs.label.

Notes for review

  • unwrap.preview_reverted / unwrap.preview_zero were position-scoped and uncorrelatedpreviewRedeem is amount-dependent, so unlike the memoized asset() probe they fire per candidate. Fixing that needed correlation threaded through Unwrapper.resolve.
  • Events deliberately left without id: probe events (venue-pair scoped — several positions share one probe), Pendle markets-list events, and the queue-wide reconcile.failed / queue.nonce_hole_cleared (exactly the refused class that is held against no position).
  • send.revert_streak gets id but no discriminator — the streak is keyed by position and spans whichever siblings reverted, so attributing it to one (slot, mode) would misreport it.
  • The rename changes the tx.* schema for the three other queue consumers too (both reallocation bots, crossed-books). Intended; their READMEs now state what their tx.*.id is and that it does not join to their own vault-keyed events.

Monitoring

The BetterStack coupling is not in dashboard SQL — charts query the metrics collection, where label('…') is the metrics-label accessor, not our field. The real dependency is two metric expressions parsing the raw field (splitByChar(':', coalesce(JSONExtract(raw,'label',…),''))[1|2]tx_market / tx_borrower), which would have gone silently blank. Both are already updated to coalesce(id, label) on prod midnight (2607569) and staging (2639416) — a no-op until this ships. Blue has no tx_* expressions, and the reallocation bots and crossed-books have no BetterStack sources at all.

Dropping the label fallback waits until this is deployed.

Verification

pnpm test 211 files / 2845 tests, 0 failed · lint 0 warnings · knip clean · typecheck across all eight affected packages · fork suite 3/3. The acceptance criterion is a test: plan.built and tx.sent must carry the same id, broadcast through the real queue — verified to fail when the production field is reverted to label.

🤖 Generated with Claude Code

@haydenshively haydenshively changed the title fix/normalize position join key fix(bots): normalize the position join key across log events Aug 31, 2026
@linear-code

linear-code Bot commented Aug 31, 2026

Copy link
Copy Markdown

BOTS-90

@haydenshively haydenshively self-assigned this Sep 1, 2026
@haydenshively
haydenshively marked this pull request as ready for review September 1, 2026 03:51
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-01T04:01:16.102663Z 564ebdd Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

haydenshively and others added 2 commits August 31, 2026 22:52
Every position-scoped log event in both liquidators now carries the
position in one field, `id`, valued `lensKey(marketId, borrower)` — the
composite the helper already lowercases on both halves. A maturity's
events therefore group into one row per position with no normalization
in the query.

Before this, the same position was identified three different ways:
`marketId` + `borrower` (checksummed) on the plan and simulate events, a
single lowercased `id` on the select and quote events, and that same
string under the name `label` on `tx.*`. Grouping by the raw composite
returned 26 rows for 13 positions on the 2026-08-28 maturity, which reads
as if the bot planned positions it never quoted.

`marketId` and `borrower` stay on `plan.built` as human-readable extras.

Since a tick is multi-collateral, `id` identifies a POSITION and the
per-candidate key is `(id, collateralIndex, postMaturityMode)`. That pair
now threads through `QuoteRequest.candidate` so the `@repo/swaps` quote
events can separate two candidates of one position; it inherits `id`'s
correlation-only contract and nothing in the package branches on it.

In the pending queue only the EMITTED field is renamed. `SubmitArgs.label`
and the in-flight map keys keep their name because they are behavioral:
`inflightLabels().has(label)` is tested every tick, so touching the value
would let a second nonce-consuming send go out for a position already in
flight. That also changes the `tx.*` log schema of the reallocation bots
and midnight-crossed-books, which pass a vault address or a market id as
the same key.

Closes BOTS-90.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four documentation claims the code did not deliver, and one duplicated
comment:

- The READMEs globbed `unwrap.*` / `queue.*` / `nonce.*` as carrying
  `id`. Both now enumerate the position-scoped events exactly and list
  what is scoped to a tick, a venue pair, the queue, or the process
  instead — a glob over a mixed set is what makes a `GROUP BY id` drop
  rows into a null bucket.
- `unwrap.preview_reverted` / `unwrap.preview_zero` genuinely were
  position-scoped and carried no `id`: `previewRedeem` is
  amount-dependent, so unlike the `asset()` probe it is not memoized and
  fires per candidate. Correlation now threads through
  `Unwrapper.resolve`, which is also why `resolveRoute` takes the label.
- The claimed `probe.*` exception was false for `probe.error`, which does
  carry the correlation. Named the four pair-scoped events instead.
- `correlationOf`'s TSDoc claimed every event in `@repo/swaps`; the
  venue selector and both unwrappers carry none.
- The `plan.built` comment was duplicated verbatim in both ticks and
  narrated two self-evident fields. The READMEs are its one home.

`docs/CONVENTIONS.md` gains the rule itself, so `lensKey`'s TSDoc, the
two READMEs and `SubmitArgs.label` point at one place rather than
restating it four times.

The three other queue consumers now say what their `tx.*.id` is: a
checksummed vault address, or a market id. It joins to nothing in those
bots, which key their own events on `vault`.

Tests: `@repo/swaps` pins the discriminator spread, the non-shadowing of
`id`, and that correlation reaches an unwrap hop; `@repo/bot-kit` pins
the emitted field name on `tx.sent` and `tx.confirmed` in the package
that owns the schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

haydenshively added a commit that referenced this pull request Sep 1, 2026
A regression sweep over #184/#187/#188/#189 found four behaviors the stack
changed without meaning to. None are new functionality; each restores what
the bots did on main.

Detection-only blue could broadcast. The no-venues gate moved to AFTER unwrap
resolution and the swap-free branch lost its `steps.length > 0` guard, so a
venue-less deployment returned `kind: 'swap'` — which the tick takes straight
to simulate+submit. docker-compose defaults Robinhood (4663) to
ALLOW_DETECTION_ONLY with a funded key, documented as skipping every routed
liquidation. `swapFreeWithoutVenues` (default false) restores the immediate
refusal; midnight opts in, because its loan-as-collateral slots need no route
and ALLOW_BAD_DEBT_ONLY is a supported posture there.

That same reorder downgraded a transient unwrapper RPC failure from
`no_config` (skip) to `failed` (arms backoff), pushing a deliberately unarmed
deployment into a suppression state machine it never entered. Refusing before
the unwrap chain fixes both at once, and spends no reads doing it.

A send REJECTION now arms backoff even when a sibling execution-reverted.
Both sets are keyed by position, so `backoffExempt` was cancelling the backoff
a broken nonce/funds/RPC send earned; the position then re-sent every block
while the send machinery was still broken.

Phase A.5 no longer resolves routes for suppressed positions. It ran ahead of
the cooldown/backoff gates, so a backed-off position spent an uncached read
per candidate per tick — breaking backoff's contract that it bounds API and
RPC usage under a backlog, and putting that latency in front of the first
send of a maturity burst.

Each of the 8 new tests was verified to fail against the pre-fix source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@haydenshively
haydenshively force-pushed the fix/normalize-position-join-key branch from a86a7a5 to 564ebdd Compare September 1, 2026 03:52
@haydenshively
haydenshively marked this pull request as draft September 1, 2026 03:54

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a86a7a520b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/bot-kit/src/queue/pending-queue.ts
@haydenshively
haydenshively marked this pull request as ready for review September 1, 2026 03:59
haydenshively added a commit that referenced this pull request Sep 1, 2026
Triaged all 20 Codex/Devin findings across #184, #187, #188, #189 and #192.

Fixed:
- the venue-less swap-free exception admitted unwrap-only plans, so an
  ALLOW_BAD_DEBT_ONLY deployment could broadcast an asset-moving liquidation
- candidate ranking trusted stale and incomplete curves that quoting refuses;
  both now share one exported `curveIsTrusted`
- a Pendle PT resolved its unwrap chain twice per tick, both hosted API calls;
  new `previewTokenOut` seam answers phase A.5 from cache
- phase A.5 resolved routes serially, in discovery order
- a sibling's `no_route` suppressed a position whose other candidate was
  `floor_unmet`, which is meant to retry every block as the LIF ramps
- the wall-clock cooldown verdict could flip mid-tick, leaving a candidate
  quoted but unpriced
- revert-streak state never expired, so a reused label reported false crossings
- `tx.submit_failed` carried no candidate discriminator
- docs: the swap-free "iff" guarantee, `selectorConstant`, two README rows

Declined, with reasoning for the threads: narrowing the backoff exemption to
post-maturity plans (a regression — the sets are per-position while the mode is
per-candidate), `stopAfterWinner`, and the sub-1e-18 rate truncation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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