Skip to content

Treat an unobserved order outcome as unknown instead of failed - #4405

Merged
TaprootFreak merged 33 commits into
developfrom
fix/liquidity-unknown-order-outcome
Jul 28, 2026
Merged

Treat an unobserved order outcome as unknown instead of failed#4405
TaprootFreak merged 33 commits into
developfrom
fix/liquidity-unknown-order-outcome

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Why

A liquidity order whose request left our side without an observed answer was recorded as Failed. That asserts knowledge we do not have — and it is not inert: a failed pipeline pauses its rule, the rule auto-reactivates after reactivationTime, and the same request goes out again minutes later.

Two Scrypt withdrawals took exactly this path and were mis-recorded:

withdrawal A withdrawal B
amount 205'589.7725 USDT 553'823.6671 USDT
recorded as Failed, no correlationId Failed, no correlationId
actually executed at the venue executed at the venue

Both are confirmed twice over: an exchange_tx row for the exact amount, and a balance drop matching the order amount to the cent.

No double withdrawal resulted — but only because the balance had already fallen by the time the rule retried. That balance is served from a push cache with no freshness guarantee and the connection has no liveness check, so the safeguard is least reliable in precisely the situation that produces the timeout.

The rule this PR applies

Never conclude an outcome that was not observed. If we cannot prove a request did not take effect, do not repeat it — look it up.

This follows the payout subdomain, which already solves the same problem for blockchain broadcasts (PayoutBroadcastException / PAYOUT_UNCERTAIN), rather than inventing a second mechanism.

What

1. Reserve the venue reference before sending. Scrypt is the one integration that lets us choose it (ClOrdID / ClReqID), but it was generated inside the service and only returned on success — a timeout lost it, leaving nothing to look up. Integrations may now supply one via reserveCorrelationId, persisted by the pipeline before the request goes out. Derived from the order id, so it meets the venue's daily-uniqueness rule without randomness and is reproducible from the row alone.

This extends to the amend boundary: checkTrade can cancel-replace or restart an order from inside the completion check, and each of those creates a new venue order. Callers now pass a replacement reference derived as <prefix><orderId>-<referencesUsed>, so a replacement whose confirmation never arrives is still findable. Reconciliation enumerates those candidates, not just the current reference.

2. LiquidityManagementOrderStatus.UNCERTAIN. Terminal for the pipeline — checkRunningPipelines already leaves such an order alone, so no rule is paused and nothing auto-reactivates — but not for the order.

3. Classify the write boundary, fail-closed. Only an explicit rejection from the venue proves a request did not take effect; the venue replied, so the outcome is known. Everything else — including a dropped socket — is an unknown outcome. A close is not proof of non-delivery: once ws.send has run the bytes may already be on the wire, and the close that follows rejects the pending request with a generic message that says nothing about whether the venue acted on them.

A rejection is carried by its own error type rather than recognised by message text, so it cannot be missed by rephrasing a message, and a transport error that happens to quote the phrase cannot masquerade as a settled outcome.

Over-classifying costs an operator a look at the venue; under-classifying is what moved money without a record. Since absence at the venue is not proof either, such an order waits for a human rather than clearing itself — deliberately the expensive direction, because the cheap one is the dangerous one.

A separate error type marks writes whose outcome was never confirmed, so the check/amend path can tell them apart from read-safe transport errors — a distinction message matching cannot make, since the same dropped socket is harmless on a read and unresolved on a write.

As a consequence idempotent reads (fetch/fetchAll, which cannot affect venue state) retry on timeout instead of ending the whole order — that alone covers 47 of the 49 timeouts observed over two weeks.

4. Reconcile instead of repeat. resolveUncertainOrders asks the venue what happened and never re-sends — and it can only ever confirm the positive. Scrypt has no terminal "this reference was never accepted" reply, so absence is not evidence at any age: such an order stays quarantined and its rule stays blocked. Reconciliation runs before any new order is issued, and tries the newest reference first, because a replaced order usually lingers at the venue in a cancelled state and would otherwise mask the live replacement.

Adoption only ever moves forwards: a reference newer than the one the row names may be adopted, a
superseded predecessor never — after an amend the original lingers at the venue as cancelled, and taking it
back would restart the very quantity the replacement is working. And a claimed reference the venue does not
show, or cannot be asked about, blocks every write on that order rather than being stepped past: the
reference is recorded before the request leaves, so it may be live at the venue at that moment.

A release is accepted, not executed. Concluding that a request never left is the one judgement nothing
here can verify from the outside, and it can be made at the very moment reconciliation is watching the venue
confirm that same order. So the release does not end the order: it is recorded, the order stays quarantined,
and reconciliation puts it into effect on the next pass — seconds later — once the venue has answered that it
has no record either. Two negatives, one of them from a person who looked. A venue that cannot be reached
answers nothing, so the order keeps blocking; a venue that confirms the order overrules the release outright.
An all-references-refused verdict still fails the order directly: that is the venue's own answer, not a
judgement.
Two exceptions, both about liveness rather than safety: an order no integration can look up any more never
gets an answer, and a venue that has been unreachable for an hour is not going to give one. In both the
release takes effect on the operator's assertion, which is exactly what it was checked for — silence stops
being a veto, it never becomes evidence.

Where a confirmed observation cannot be written back at all, the order goes back into quarantine rather
than being merely reported — an alert is acted on at human speed while a rule reactivates in minutes — and
the alert goes out on top of that state, naming the order and the reference.

Since nothing releases a genuinely unsent order automatically, there is a guarded manual path (PUT liquidityManagement/order/:id/resolveUncertain, admin only). It mirrors the payout subdomain's retry guard: the caller must assert that the venue was checked and name where, and the assertion is written onto the order — where it stays, since nothing overwrites it.

Observing is not deciding

A failure to read an order the venue has acknowledged no longer ends it — that would release the rule to open a second position against the same funds. Such a check is retried, with a dedicated type for "acknowledged, then vanished" that quarantines instead of failing.

Status filters

UNCERTAIN counts as pending wherever an in-flight order matters, so an unresolved order cannot be stepped over:

  • hasPendingOrders — the gate that stops a second rule on the same exchange from acting while funds are unaccounted for.
  • getProcessingOrders — unfinished business belongs in the operator view.
  • the custom-asset balance sum — the generic quarantine is not Scrypt-specific, so a Kraken/Binance order can land there too.

getPendingTx, which feeds the financial log, deliberately does not include it. The log adds a pending amount back to the balance and nets it against the venue's locked funds; a quarantined order may have locked nothing, so counting it would inflate equity by its full amount — the one error direction that can hide a real loss from the safety threshold.

Observability

So a quarantined order is not silently parked:

  • uncertainLmOrderCount on the liquidity observer — no age threshold, one such order is already worth surfacing.
  • Quarantine mail pinned per order, debounced hourly.
  • Pipeline-failure mail pinned per rule and cause (digit-insensitive hash of the message) and debounced. Keyed by rule alone it would swallow a genuinely different failure within the window; keyed by pipeline it would restore the flood, since every retry is a new pipeline — one incident previously produced 18 mails in two hours.
  • Success mail dropped: 211 of 255 liquidity mails in one week, carrying no information and no action. That volume is what made the mails that matter unreadable.

Incidental

An error outside the known exception types left the order in Created while startNewOrders reported a change regardless, so the caller's while loop could not terminate. Such an error now settles the order — quarantined if a reference had already been reserved (the send boundary was crossed), an ordinary failure if not, since nothing can have been transmitted.

Notes for the reviewer

  • One additive migration. status is a varchar, so the new value needs no schema change, and the reserved reference reuses the existing correlationId column. What does need one is notSentRecheckDue (nullable, indexed, no backfill). It marks an order somebody has released as never sent, and while it stands the order stays quarantined. On the row rather than in memory, so a restart does not lose the pending release, and cleared once the venue has answered. It records work outstanding, not when the release was asked for: that moment goes into the order’s own reason, which nothing clears.
  • Deliberately not included: a connection heartbeat. A half-open socket is currently undetectable, which is the structural cause behind the timeouts — but the venue specification does not document whether it answers WebSocket ping frames, and a watchdog built on an unverified assumption would terminate healthy connections. Worth a follow-up once that is confirmed.
  • Open question, deliberately routed around: the specification requires ClOrdID uniqueness but does not promise that a duplicate is rejected. That is exactly why the design reconciles rather than retries — the answer stops mattering.
  • Two guards on the same thing. When the venue confirms an order, the first write is the narrowest one
    possible — status: Uncertain plus clearing the pending release, matched against Uncertain, Failed or
    NotProcessable — so that from then on no judgement can end it and one that already had is repaired,
    whatever fails afterwards. And while any confirmed observation remains unrecorded, the pipeline pass stops
    after reconciliation rather than starting or advancing anything on a picture where an order says failed
    while the venue works it.
  • A residual window, shared with the rest of this cron. Two API instances can run reconciliation at the
    same moment — the cron lock is per process, as it is everywhere else in this codebase. If one instance is
    told by the venue that the order does not exist while another is told it does, the first can complete a
    pending release milliseconds before the second re-quarantines the order, and a pipeline advancing in that
    gap could start its next action. Ending an order now compares and sets on the exact release examined,
    which removes every stale-read variant of this; closing the last of it needs cross-instance serialisation
    that no job in this subdomain has today, and inventing it here felt like the wrong place. Worth a look if
    the deployment is ever multi-instance.
  • Knowingly left open: the same "timeout → Failed, no lookup" gap exists for the ccxt integrations (Kraken, Binance, MEXC, XT). Items 2–4 live in shared code and are available to them, but wiring them up needs a client-order-id per venue (ccxt sets none today) and a reconciliation lookup per venue. Propagating the unknown outcome without those would quarantine orders across every exchange with no way out but a human — a worse operating position than today, on venues where nothing has gone wrong. It belongs in its own change, with its own evidence.

Consumer impact — checked

No public API contract changes. LiquidityManagementOrderStatus appears in no DTO, and both endpoints that
touch these orders (GET in-progress and the new PUT :id/resolveUncertain) are RoleGuard(UserRole.ADMIN)
and @ApiExcludeEndpoint(), so neither is part of a published surface.

Uncertain is a new value on an existing field, so the question is whether any consumer enumerates that
field exhaustively. None does: every consumer in this repository filters for Complete (the accounting
consumer, the ledger cutover, the dashboard reconciliation) — and an order whose outcome is unknown must
not be booked, which is exactly what that filter already does. The service front end never reads this
subdomain at all. No sync is needed anywhere.

Verification

  • Full tsc --noEmit: clean
  • liquidity-management + exchange suites: 12 suites, 178 tests, all passing
  • ESLint and Prettier: clean

A liquidity order whose request left our side without an observed answer was recorded as
Failed. That asserts knowledge we do not have, and it is not inert: a failed pipeline pauses
its rule, and the rule auto-reactivates after reactivationTime, so the same request is issued
again a few minutes later.

Two Scrypt withdrawals hit exactly this path and were mis-recorded. Both requests timed out
waiting for the BalanceTransaction update; both were stored as Failed with no correlationId,
and both had in fact executed at the venue - the exchange transaction and the balance drop
match the order amount to the cent. No double withdrawal resulted, but only because the
balance had already fallen by the time the rule retried. That balance is served from a push
cache with no freshness guarantee, so the safeguard cannot be relied on in the very situation
that produces the timeout.

Four changes, following the payout subdomain, which already solves this problem for
blockchain broadcasts:

1. Reserve the venue reference before sending. Scrypt is the one integration that lets us
   choose it (ClOrdID / ClReqID), but it was generated inside the service and only returned on
   success, so a timeout lost it for good and left nothing to look up. Integrations may now
   supply a reference through reserveCorrelationId, which the pipeline persists before the
   request goes out; the id is derived from the order id, satisfying the venue's daily
   uniqueness requirement without a random component.

2. Add LiquidityManagementOrderStatus.UNCERTAIN, the counterpart to PAYOUT_UNCERTAIN. It is
   terminal for the pipeline - checkRunningPipelines already leaves such an order alone, so no
   rule is paused and nothing auto-reactivates - but not for the order.

3. Classify the send boundary fail-closed. Request timeouts now carry their own error type
   rather than a message string, so they can be told apart from a dropped socket, which proves
   the request never completed. Only the latter stays an ordinary failure. As a consequence
   idempotent reads - fetch and fetchAll, which cannot affect venue state - retry on timeout
   instead of ending the whole order; that alone covers 47 of the 49 timeouts observed over
   two weeks.

4. Reconcile instead of repeat. resolveUncertainOrders asks the venue what happened and never
   re-sends. Absence only counts as proof after a grace window, an unreachable venue leaves the
   order in quarantine, and reconciliation runs before any new order is issued.

Observability, so a quarantined order is not silently parked: uncertainLmOrderCount is exposed
on the liquidity observer, the quarantine mail is pinned per order and debounced, and the
pipeline failure mail is pinned per rule and debounced - one incident used to send a mail per
retry. The success mail is dropped; it accounted for 211 of 255 liquidity mails in a week and
carried no information, which is what made the mails that matter unreadable.

Also fixes an unrelated spin: an error outside the three known exception types left the order
in Created, and startNewOrders reported a change regardless, so the caller's while loop could
not terminate.
Three gaps in the first pass, all of the same shape as the bug it fixes.

The amend boundary was still open. checkTrade may cancel-replace or restart an order from
inside the completion check, and both created a fresh reference that was never recorded, so a
replacement whose confirmation never arrived could not be looked up even in principle - and the
error fell through to OrderFailedException, which pauses the rule and reissues the trade.
Callers now supply the replacement reference, derived from the order row as
<prefix><orderId>-<referencesUsed>, so it is reproducible without an extra column. An
unconfirmed outcome there quarantines the order, and reconciliation enumerates the
replacement candidates rather than only the current reference.

"Connection closed" was treated as proof that nothing was sent. It is not: requestWithId hands
the payload to the socket before registering the pending entry, and a later close rejects that
entry with a generic message that says nothing about whether the venue acted. The
classification is now fail-closed - only an explicit rejection from the venue, which proves the
request was seen and refused, keeps an error an ordinary failure; everything else is an unknown
outcome. Over-classifying is self-correcting, because an error that truly happened before the
send leaves no trace at the venue and reconciliation settles the order as failed after the
grace window.

hasPendingOrders did not count quarantined orders. That gate is what stops a second rule on the
same exchange from acting while funds are unaccounted for, so an unresolved order has to block
there. Same reasoning applied to getProcessingOrders, and to getPendingTx, where omitting the
status would have dropped the amount in question out of the financial log.

Alert debouncing was keyed by rule alone. Suppression compares only the key, never the body, so
a genuinely different failure of the same rule was swallowed within the window. The key now
includes a digit-insensitive hash of the cause: true repeats still collapse, a new cause still
reports. Keying by pipeline instead would have restored the flood, since every retry is a new
pipeline.

Tests: the amend and reconciliation paths, quarantine on socket close, a venue rejection staying
an ordinary failure, replacement-reference derivation, a pipeline left untouched while its last
order is quarantined, and the timeout type and read retry in the connection itself.
Follow-up to the previous commit, from a second review pass.

The amend fix did not fire. checkTrade's inner catch swallowed every error that was not
TradeChangedException, attempted a best-effort cancel and returned false, so a timeout or a
dropped socket during editOrder never reached the classification added for it - the order kept
polling the old reference while a replacement created under the reserved one stayed invisible,
and reconciliation never ran because the order never entered quarantine. Unless the venue
explicitly rejected the amend, that path now raises a dedicated unconfirmed-write error, and the
same guard wraps the restart in the cancelled branch.

That error type is what the check path was missing. Message matching cannot separate a
dropped socket on a read from one on a write, yet the first is harmless and the second is
unresolved; the completion check now tests for the write boundary before the transient-error
branch, which previously classified a connection drop during a restart as retry-next-tick. The
rejection markers move next to the error types so the send path and the check path share one
definition, extended with the edit rejection.

The custom-asset balance sum was left out of the previous pass on the grounds that Scrypt is
not among its systems. That reasoning was wrong: the generic quarantine in startNewOrders is
not system-specific, so a Kraken or Binance order can reach the status too, and neither adapter
can resolve it - its amount would silently drop out of the balance for as long as it stayed
there.

Tests cover the amend boundary end to end: an unconfirmed write surfaces as an unknown outcome,
carries the replacement reference into the quarantine reason, and a plain dropped connection on
the read path still resolves as retry-next-tick.

The PR description has been rewritten to the final state; it still described the first
iteration and, on the dropped-socket classification, asserted the opposite of what the code now
does.
…owed

The previous commit made an unconfirmed amend propagate out of checkTrade, but the guard was
only exercised through the adapter. The defect lived one layer below, in the service, and the
service had no checkTrade tests at all - which is why the first attempt at this fix looked
right and did nothing.

Three tests at that level: an unconfirmed amend leaves checkTrade as an unconfirmed-write error
instead of being cancelled away and reported as "not complete"; the raised error carries the
reserved replacement reference, so the order can be reconciled against it; and an amend the
venue explicitly rejected still falls back to cancel-and-continue, since a rejection proves
nothing was created.
A cross-vendor review of the previous state found four more spots where the code still concluded
something it had not seen. Each is the same mistake the PR set out to remove.

A CREATED order that already carries a reserved reference is no longer re-sent. That
combination can only arise when a previous pass reached the send boundary and died before
recording the result, so sending again is precisely the duplicate we are trying to avoid; it now
goes straight into quarantine.

The catch-all no longer quarantines everything. Whether a reference was reserved tells us
whether the send boundary was crossed at all: without one, nothing can have been transmitted and
an ordinary failure is correct. Quarantining those stranded configuration and factory errors in
a state only a human can clear - and, since reconciliation needs the same integration that just
failed to load, they could never clear themselves.

Reconciliation checked the oldest reference first. A replaced order usually still exists at the
venue in a cancelled state, so the original matched, the order was reported as sent, and the
live replacement stayed untracked while the completion check polled a dead reference. Candidates
are now tried newest first.

Absence is no longer treated as proof of non-arrival. The venue offers no terminal "this
reference was never accepted" reply, so a missing record after any amount of time is not
evidence; concluding otherwise is what would let a rule reissue a request that later
materialises. Such an order stays quarantined for a human, and its rule stays blocked. The grace
window existed only to make that inference safer and is gone with it.

Two more, from the same principle. A failure to READ an order the venue has acknowledged no
longer fails it - that would release the rule to open a second position against the same funds -
but is retried, with a dedicated type for "acknowledged, then vanished" that quarantines instead
of failing. And the quarantined status is taken back out of the financial log's pending set: the
log adds a pending amount back to the balance and nets it against the venue's locked funds, but
an unsent order locks nothing, so counting it would inflate equity by its full amount - the one
error direction that can hide a real loss from the safety threshold.
A follow-up review found four more places where the code still had to guess.

Rejections were recognised by message text, and one real terminal path phrased its message
differently ("Order <id> has been rejected"). It matched nothing, so after the previous commit
made unmatched errors retry instead of fail, a genuinely rejected order would have been retried
forever. Rejections now carry their own type, used by every path that turns a venue refusal into
an exception: it cannot be missed by rephrasing, and a transport error that happens to quote the
phrase can no longer masquerade as a settled outcome.

Reconciliation recovered a withdrawal from the venue's history but never cached it, while
getWithdrawalStatus reads only the cache. An order leaving quarantine on the strength of that
lookup would have polled a reference the cache still did not know, and never completed. The
match is now fed back through the same terminal-aware cache write as a live push.

A bare request timeout in the completion check can only come from a read, because every write
there is already wrapped. Quarantining it stranded an order that nothing at the venue had
touched, so it is retried like any other read problem.

And the gap left by the previous commit: with absence no longer proving non-arrival, nothing
released a genuinely unsent order, so its rule would stay blocked indefinitely with no operator
action available. There is now a guarded admin endpoint, modelled on the payout subdomain's
retry guard - the caller must assert that the venue was checked and name where, and the
assertion is recorded on the order.

Also corrected: a comment claimed the socket send precedes registering the pending request,
where the code does the reverse; the conclusion it supported is unaffected, since what makes a
close ambiguous is that the bytes may already be on the wire. Stale grace-window wording removed
from code and description, and the description now matches the final behaviour, including the
deliberate exclusion of the quarantined status from the financial log's pending set.
Seventh pass. Six findings taken, one declined.

A refused amend now reports itself. checkTrade swallowed the refusal and returned "not
complete", so the caller never learned that the replacement reference was spent - the venue
requires them to be unique, so the next tick derived the very same one and the pair could loop.
The refusal carries the spent reference, the order records it, and the derivation moves on while
tracking stays on the original, which a refused amend leaves live.

Reconciliation no longer adopts a rejected replacement. Any returned record counted as proof of
sending, so a replacement the venue had refused would be adopted, immediately fail the order,
and release the rule although the original was still standing.

Both resolution paths now write conditionally. Automatic reconciliation and an operator can hold
the same order at once, and an unconditional save let whoever finished last win - including
failing an order the venue had just confirmed as live. The status is part of the WHERE clause
and a lost race is skipped, or reported as a conflict on the manual path.

A venue error reply is no longer a plain error. It carries its own type, deliberately distinct
from a rejection: "unknown reqid" arrives the same way and means the venue lost our request
context, which for a mutation is as open as silence.

A cached withdrawal record that is not terminal no longer short-circuits the history lookup -
the missing terminal push is exactly what such a record would be hiding.

The manual endpoint was too thin: the asserted flag and the caller were dropped at the edge, a
whitespace-only reference passed validation, and the log line preceded the write. The service
now re-asserts the claim, normalises and caps the reference, records who authorised it, persists
before logging, and returns nothing rather than the entity.

DECLINED: extending this to the ccxt venues. They share the gap, but they have no client order
id today and no reconciliation lookup, so propagating the unknown outcome would quarantine
orders across every exchange with no exit but a human - a worse position than today, on venues
where nothing has gone wrong. Recorded in the description as its own piece of work.
Conformity pass. The manual-resolution reference was unbounded while both analogous DTOs in the
repository cap it at 1024 characters, and it was written to the log untruncated; the cap now
lives in the DTO, so the service records the validated value instead of silently shortening it.
Two update mocks in the tests dropped their types for no reason and are typed again.

On the contract question: the new order status appears in no DTO, and both endpoints that expose
these orders are admin-guarded and excluded from the published surface, so there is no public
contract change. Whether the internal admin view enumerates statuses exhaustively cannot be
determined from this repository, so the description asks the reviewer to confirm it rather than
leaving the question unasked.
The compare-and-set added earlier decides who writes first, and first is not the same as right.
If an operator releases a quarantined order in the same moment reconciliation confirms the
request exists at the venue, the operator's write lands, the order is marked failed, the
pipeline releases and the rule can issue a second order against a live position.

The manual path now asks the venue itself before releasing, and refuses when the reference is
found - a positive observation outranks the operator's judgement, and the order is left for the
next reconciliation pass to move on. A lookup that cannot be performed does not block the
release: the operator has asserted an independent check, and a reference the venue can no longer
be asked about must not turn into an order nobody can ever clear.
… unobservable case

Three findings from the sixth pass, all of them consequences of the previous round's fixes.

A replacement reference was only recorded once it was accepted or explicitly refused. In between
it was neither the current reference nor a spent one, so a replacement whose confirmation was
lost could be derived a second time. The reference is now claimed and persisted immediately
before the request goes out, for both the amend and the restart.

Reconciliation could still fall through to a superseded predecessor. An accepted replacement may
simply not be visible at the venue yet, while the order it replaced still is; matching the older
one reported the request as sent and left the live replacement untracked. An older reference is
now considered only after the newer one was explicitly rejected - anything else keeps the order
quarantined.

A positive observation on the manual path was refused but not recorded. The row stayed
quarantined, so a later attempt made while the venue happened to be unreachable could still
release it and undo what had been seen. The observation is now persisted first, which takes the
order out of manual reach entirely.

And an acknowledged order that cannot be observed no longer polls forever. It was kept
deliberately - failing it would release the rule against a live position - but the manual path
only accepts quarantined orders, so there was no way out at all. Past the same age at which the
venue itself is considered to have lost an order, it is quarantined: still not declared failed,
but now reachable for a human.
The seventh pass found that the previous commit had broken the very case this work exists for.

Reconciliation built its candidate list by deriving the NEXT reference and checking that first.
For a freshly quarantined order that reference has never been sent, so it was absent — and since
the previous commit made an absent newest reference stop the search, the reference that had
actually gone out was never looked at. Every quarantined order would have stayed quarantined
until somebody released it by hand. The tests hid it by answering every queried reference.

Reconciliation now walks exactly the references this order has put on the wire, newest first,
ordered by attempt number rather than by how the list happened to be stored. Nothing is
synthesised.

Two more from the same pass. The age bound that stops an unobservable order from polling forever
sat behind the transient-transport branch, so an old order whose socket kept dropping never
reached it — a dropped socket that keeps dropping is, from here, indistinguishable from one that
will never answer, so the bound now applies to both.

And a positive observation could still lose the write race: if somebody released the order as
not executed while reconciliation was watching the venue confirm it, the release landed and the
observation was discarded. An observation outranks a judgement, so it is now taken back — the
order returns to in progress, with an alert, because whoever released it needs to know their
check missed something.
Five findings from the eighth pass.

The reclaim introduced last time was too broad. It matched any failed row by id, so an order that
had ended for an entirely unrelated reason could be resurrected by a late positive observation.
A not-sent resolution now stamps its failures, and only a failure carrying that stamp can be
taken back — the reclaim exists to overrule a judgement, never the venue.

The manual path ignored whether its own positive observation actually landed. If a negative
resolution won the write, the observation was discarded; it now reclaims in exactly the same way
as automatic reconciliation.

Three paths could wait forever. An acknowledged withdrawal whose terminal update is never seen,
an order left in a pending status whose update is missed, and - already bounded for trades - the
unobservable case, all answered "not complete" indefinitely while the manual path accepts only
quarantined orders, so there was no way out at all. All three now fall under the same age bound
and become unknown outcomes rather than failures.

And reconciliation can conclude a negative again. When every reference an order ever put on the
wire comes back rejected, nothing was created — unlike mere absence that is a real negative, so
the order is resolved instead of being asked about forever while its rule stays blocked.
@github-actions

Copy link
Copy Markdown

❌ ESLint: 4 errors, 1 warnings

The previous commit added a second case block for the pending statuses instead of extending the
one already there, which is a duplicate-case lint error. The bound now lives in the existing
branch, and the sixty minutes it shares with the "order cannot be found" path is a named
constant rather than a literal repeated in two places.

This should have been caught before pushing: the lint run had already reported it and the commit
went out anyway.
The ninth pass found the two drifting together again. The previous commit quarantined an order
that had been pending too long, and a withdrawal the venue knew about but had not settled. Both
are OBSERVATIONS - we know exactly where the order stands - so quarantine was the wrong shelf:
reconciliation found the reference, handed the order straight back as sent, and the next
completion check quarantined it again. Every transition reported a change, so the pipeline loop
kept going round, querying the venue each time, while manual release refused because the
reference existed.

Quarantine now means only what it says: we cannot tell whether the request took effect. A
pending order simply waits, however old it is, and a withdrawal the venue has a record for waits
too. Only the genuine blind spot - no record at all, past the age at which the venue is
considered to have lost an order - is still quarantined. An order that waits too long is a stuck
order, which the monitoring counter already surfaces; it is not an unresolved one.

The same pass found no route to an automatic duplicate execution across the initial send, amend,
restart, rejection, reference-claim and reclaim paths.
…eal blind spot

Two findings from the tenth pass, plus three conformity points.

A replacement the venue had accepted could be lost. The claim persists the new reference before
sending, but adopting it afterwards is an ordinary save - and if that save failed, the row still
named the predecessor while the venue had already cancelled it. The next check restarted from
that predecessor and placed a second order alongside the live replacement. Before anything may
write again, a claimed reference the venue is working is now adopted; a rejected one is skipped.

The age bound was too broad. It fired for any failure after the check began, so an order the
venue could still show us was quarantined because pricing or aggregation had failed - and
reconciliation, finding the reference, handed it straight back for the next check to quarantine
again. The bound now applies only when the order itself cannot be observed.

From the conformity pass: the route follows the repository's camelCase majority (70 to 32); the
audit reference is trimmed before validation, as eleven other string DTOs do; and the new tests
use typed venue fixtures instead of widened literals.

Not changed, with reason: the demand to remove every `any` from tests contradicts the verified
house convention - the pattern appears 1441 times across this repository's specs. The cases where
typing was straightforward are typed; the convention is not overturned in passing.
Three sequences found in review could still end in a second request against the same funds.

A predecessor is not a replacement. After an amend this row DID record, the superseded
original sits in the reference list as cancelled — and adoption, which only skipped the
current reference, would take it back and restart the very quantity the replacement was
working. Adoption now considers references newer than the current one only.

A claimed reference the venue does not show, or cannot be asked about, is not a refusal.
It is recorded before the request leaves, so it may be live at the venue right now; the
check may not step past it and carry on with the predecessor. Such a claim now blocks
every write on the order, and the wait ends the same way any other blind spot does: past
the age at which the venue itself is considered to have lost an order, it goes to a human.

Applying a positive observation takes two writes — release the quarantine, or take back a
negative resolution that got there first. A crash or a failed write in between left the
order in a state nothing ever read again, while its rule was free to plan anew. Failures
stamped as not-sent are now re-examined alongside the quarantined ones for an hour, so the
second write is simply retried.

Six of the seven added tests fail without the corresponding change; the seventh pins the
existing two-step reclaim.
… release

An observation that the venue does know the order becomes durable in exactly one write. If
that write does not land — the process ends, the statement fails — the observation is gone,
and the order sits failed while the venue is working it. The hour-long window that covered
this was itself a way to lose it, so there is no window any more: a failure stamped as
not-sent stays eligible for reconciliation without an age limit.

What that would otherwise cost is a venue lookup per settled failure on every tick, for the
rest of its life. So each such order is asked about once per process instead. That is the
same coverage — the observation can only be lost by this process ending or its write
failing, and neither survives into the next one — without the standing load.

The release endpoint updates an order, so it is a PUT, per the REST section of
CONTRIBUTING. One test also went back to the typed venue-order helper instead of casting a
literal.
Two review passes chased the same thing from opposite sides: re-examining terminal
failures kept a lost observation recoverable, but it also meant a leading-wildcard scan
over every historical failed row on each pipeline run, and the memo that bounded that cost
treated an inconclusive lookup as settled — so a delayed confirmation was suppressed for
the life of the process.

Both go away with the mechanism. Reconciliation is back to quarantined orders only. The
reclaim stays: it is one conditional statement and it still catches a negative resolution
that lands mid-pass.

What was missing was never the scan, it was the admission of failure. When the venue has
confirmed an order and neither release matches — or the write throws — that observation is
the one fact worth having, and it was being dropped as a log line. It now raises an alert
naming the order and the reference, telling whoever reads it to treat the order as live at
the venue. Consistent with the rest of this change: where we cannot be sure, a person
decides, and no rule plans against those funds in the meantime.

A release someone else performed correctly is recognised and stays quiet.
An alert is read at human speed; a rule reactivates in minutes. So reporting that the
venue confirms an order — while the row stays failed — still leaves the scheduler free to
plan against funds that are already committed. Reporting was the wrong half of the answer.

Anything short of a clean release now puts the order back into quarantine, which is the
state this subdomain already treats as in flight with an open outcome: no rule plans
against it, and the next reconciliation pass simply tries again. The alert stays, but on
top of a state that holds by itself.

The manual release had the same shape and now takes the same path: its refusal no longer
depends on a write it never checked. Its message says which of the two happened.

The report also no longer depends on a second read succeeding — that read is only what
lets a genuinely safe outcome stay quiet, and a state that cannot be read is not one of
them.
…ow already says

A not-sent failure is eligible for reconciliation again, so an observation whose writes
never landed — including the re-quarantine that normally catches that — is still applied
afterwards. The bound on it is the venue's own reach: Scrypt's execution history goes back
thirty days, and past that nobody can make an observation to apply. That is deliberately
not a deadline for applying one, which is how the previous attempt could lose it; it is
also what keeps the query off the entire failure history.

Second: overruling a resolution was erasing the record of it. Both write-backs built their
message from the copy of the row this pass started with, which predates the resolution
being overruled — so a reclaim replaced the account that released the order and the
reference they checked with a stale string. Both now read the row and append to what it
actually says, and where it cannot be read, only the status changes.
Finding the failures that still owe reconciliation a second look was done with a wildcard
match over every failure ever recorded, on a job that runs every ten seconds, and the
attempts to bound that cost kept trading one hole for another: an in-memory memo lost the
obligation on restart, a time window turned into a deadline for applying an observation.

The obligation now lives on the row. A not-sent resolution stamps notSentResolvedAt; the
first reconciliation pass that looks at the order again clears it. That is the coverage
that was wanted — the pass writing such a resolution may be racing one that has just
watched the venue confirm the same order, and an observation that could not be written
would otherwise be gone — and it survives a restart, because it is a column and not a set
in memory. Cleared after that one look, so settled failures are not put to the venue every
ten seconds for good. Indexed, so finding them is an equality predicate.

Two smaller things, both about not destroying evidence:

The reclaim and the re-quarantine now guard their write on the exact reason they just
read. Another resolution can land in between, and appending to the older copy would erase
the newer operator and reference.

And the verification reference is trimmed at the edges only. Util.trimAll removes every
space — it is for identifiers like IBANs — and it was turning "venue console, ticket
OPS-42" into one run of characters. That field is evidence somebody has to read.

The migration is additive and nullable, with no backfill. It was not run against a
Postgres instance: this machine has none.
…ent in the reason

Clearing the marker was guarded by order and status alone, so a pass that had been
looking at one resolution could clear the marker of a newer one written in the meantime —
dropping the further look that newer resolution was owed, which is the whole point of the
marker. The clear is now conditional on the exact marker the pass started from.

A marked order whose integration has no lookup at all could never have its recheck
happen, so the marker stood forever and every pass selected the row only to skip it. Such
a row is released instead.

And the column is named for what it is: work still owed, not a record of when the
resolution happened. That moment now goes into the order's own reason, which nothing
clears — so releasing the marker cannot make it unrecoverable.
The rename to notSentRecheckDue added the new migration but left the old one in place, so
both would have run and the table would have gained two columns instead of one. Only the
new file belongs here; neither has been merged, so there is nothing deployed to correct.
A venue that cannot be reached was reported the same way as a venue that answered and had
no record. Both arrived as UNRESOLVED, so the recheck an order still owed was retired on
the strength of a lookup that never happened — and with it the observation it was being
kept for. A failed lookup is now UNAVAILABLE: no question reached the venue, so nothing
was looked at, and the order stays marked until something actually is.

Separately, an order can outlive the adapter that made it. The integration factory returns
null for a system or command that is no longer registered, and both the reconciliation
loop and the manual release dereferenced it: the loop would have thrown on every pass
forever, and the manual release refused an order that nothing could ever look up again —
leaving it quarantined with no way out. Both handle the absence now, and a marked failure
nobody can ask about is released rather than selected and skipped for good.
A release said the request never left, and the order became a plain failure on the spot.
That is terminal: the pipeline finishes, the rule reactivates, and it may issue the request
again — all while a confirmation that the order IS live could still have been in flight and
simply lost its race to the write. Keeping such an order reconcilable afterwards did not
help, because between the release and any later correction the rule was already free.

So the release no longer ends the order. It is recorded, the order stays quarantined, and
reconciliation puts it into effect on the next pass — normally seconds later — once the
venue has answered that it has no record either. Two negatives, one of them from a person
who looked. A venue that cannot be reached answers nothing, so nothing happens and the
order keeps blocking; a venue that confirms the order overrules the release outright.

An all-references-refused verdict still fails the order directly. That is the venue's own
answer, not a judgement, and it needs no confirming.

This removes the reclaim path entirely: nothing writes a failure behind a pending release
any more, so there is no negative resolution left to take back.
Ending an order is the one step here that cannot be taken back, so it now compares and
sets on the exact pending release the pass looked at. A release written since has a
confirmation of its own outstanding, and completing the older reading would skip it.

The one case where a release does not wait for the venue — an order no integration can
look up any more, where no answer can ever come — is now stated in the same places the
rule is: the column, the DTO, the migration and the pull request text. It was only in the
code.

The release tests asserted against the in-memory entity, which the reconciliation loop
would have left looking correct even if nothing had been stored. They now assert the
conditional write itself, its predicate and its payload, and that the pass reports a
change.

One comment still described the reclaim path that was removed.
When the venue confirms an order and the repair that holds it blocking cannot be written,
the failure was swallowed. The order stayed a plain failure — nothing selects one of those
again — while the venue worked it, and after the rule reactivated the request could go out
a second time.

The write is kept and repeated at the start of each pass, ahead of any new lookup. It asks
the venue nothing; it only repeats what is already known, and it stops as soon as it lands
or another path has put the order somewhere safe. Held in memory deliberately: the
alternative is a durable queue nothing ever drains, and the one case this cannot cover —
the process ending first — is covered by the alert that goes out at the same moment, which
names the order and tells whoever reads it to treat it as live.

Also: the branch that completes a release for an order nothing can look up now reports the
change like every other branch, so the caller's loop sees it.
…until it is

Two ways a confirmed venue order could still end up terminal.

The first: applying an observation is a substantial write, and everything about it can
fail. So the very first thing now is the narrowest write in the file — one column on a row
that is still quarantined, cancelling any pending release. Once that lands, no judgement
can end the order, whatever fails afterwards and even if this process stops. It is also
what makes a quarantined row genuinely safe to stop retrying on: quarantined WITH a release
pending is one inconclusive lookup away from being ended, and that is exactly what the
observation contradicts.

The second: holding an unwritten observation in memory kept it alive, but the rest of the
pass carried on regardless — starting pipelines, advancing them, issuing orders — on a
picture in which an order says failed while the venue is working it. The pass now stops
after reconciliation for as long as any observation is unrecorded, and the next one retries
the write before asking the venue anything.
…he after an unconfirmed cancel

The first write for a confirmed order only stripped a pending release of its power. If a
release had already ended the order, that write matched nothing and the repair came later
— so until it did, the only thing between a live venue order and a second request was this
process staying alive. The same statement now also puts an ended order back into
quarantine. Two columns, no appended text, nothing it depends on; the reason why still
follows separately, and if that never lands the order is at least still blocking.

Second: when an amend is refused, the fallback cancel is a write as well. Its failure was
noted and dropped. Unconfirmed, that cancel may have taken effect at the venue while the
cached report still shows the order open — and a non-terminal cached report is never
replaced by a later fetch, so every check afterwards would keep waiting on a picture that
cannot change, with the order stuck in progress and out of reach of both reconciliation and
the manual path. The cached report is dropped instead, so the next lookup has to ask the
venue, and the refusal says the cancel went unconfirmed.
A release waits for the venue to answer, which is right — but a venue that answers nothing
at all would have held a verified order for good. The wait exists to catch a confirmation
that is in flight right now; after an hour of silence there is none in flight, only an
operator who checked and is being ignored.

This is a liveness bound, not a safety one, and nothing is concluded from the silence: the
person who released the order concluded it. Silence merely stops being a veto.

Also: an import out of alphabetical order.
…everywhere

Whether a pending release has waited out an unreachable venue is something the order
knows about itself, so it is answered by the order, with the bound alongside it, and the
service is left with the orchestration.

And the promise that a release waits for the venue was written in six places, all of which
still named only one way out of it. Both are now stated wherever the rule is: an order no
integration can look up, and a venue that has answered nothing for long enough. Neither
concludes anything from silence — silence stops being a veto on the person who checked, it
never becomes evidence.

The entity gains its own tests, including the boundary either side of the wait.
The same promise is written in several places and two of them still named only one way
the wait can end. No behaviour change.
The new import went in at the top instead of in order, and the two around it were already
out of order. No behaviour change.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

This took 27 review passes to reach a clean bill on both correctness and conformity. Recording what they
changed, because most of it was not cosmetic and the shape of the fix moved several times.

Double execution, closed in layers. Early passes found that adoption of a claimed replacement could walk
an order backwards onto a cancelled predecessor and restart the very quantity a live replacement was
working; that a claimed reference the venue does not show — or cannot be asked about — was being stepped
past rather than treated as a barrier; and that quarantining on total order age rather than on a genuine
blind spot bounced observable orders between states.

A release that ended an order too early. The largest change came late. Concluding that a request never
left is the one judgement nothing here can verify from the outside, and it was taking effect immediately —
making the order terminal, freeing its rule — while a confirmation that the order is live could still have
been in flight. A release is now accepted rather than executed: the order stays quarantined until the venue
has answered too. Two liveness exceptions, neither concluding anything from silence: an order no integration
can look up, and a venue unreachable for long enough.

Making a confirmed observation survive. Several passes circled the same question — where does "the venue
confirms this order" live if the write fails? The answer settled on: one narrow write first, which puts the
order back into quarantine from any state a release may have left it in; the substantial write after; the
whole pipeline pass held while any observation is unrecorded; and the write retried until it lands. What was
tried and discarded along the way: an expiring reclaim window (itself a way to lose an observation), a
wildcard scan over the entire failure history on a ten-second job, and an in-memory memo that treated an
unreachable venue as a settled answer.

Audit trail. Overruling a release was overwriting the account that made it and the reference they
checked, because both write-backs used a copy of the row read before the race. They now append to what the
row actually says, guarded on that exact value. The verification reference is trimmed at the edges only —
it was being stripped of every internal space.

Smaller, still real. An unconfirmed cancel after a refused amend left a stale open order in the cache
that no later fetch would replace, stranding the order in progress and out of reach of both reconciliation
and the manual path. An order can outlive the adapter that made it, which threw on every pass. A failed
lookup and an answered-but-inconclusive one were indistinguishable.

Two points were argued and deliberately not changed; both are described in the PR body. The first is a
residual window that needs two instances receiving opposite answers from the venue in the same instant —
closing it needs cross-instance serialisation that no job in this subdomain has today. The second is the
lost-order age bound, which is a floor on how long to wait before involving a person, not a measurement of a
blind spot.

Verification: 12 suites, 178 tests. Every behavioural change is pinned by at least one test that fails
without it — checked by reverting the source and re-running. The migration was not run against a live
database instance.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 28, 2026 00:52
@TaprootFreak
TaprootFreak merged commit aa79ebc into develop Jul 28, 2026
13 checks passed
@TaprootFreak
TaprootFreak deleted the fix/liquidity-unknown-order-outcome branch July 28, 2026 06:17
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