Skip to content

fix(0392): decide NFT membership before the write, retire nft-reclassify - #358

Closed
karolko9 wants to merge 9 commits into
developfrom
fix/0392_nft-pending-live-routing-reconcile
Closed

karolko9 wants to merge 9 commits into
developfrom
fix/0392_nft-pending-live-routing-reconcile

Conversation

@karolko9

@karolko9 karolko9 commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

Note on history: the first three commits on this branch implemented a
read-time visibility filter. Review rejected it — "if a row is in nfts, it
should be an NFT"
— so commits 4–6 replace it with a write-time decision. The
rejected design and its measurements are kept in ADR 0053 as an alternative,
not deleted.

What

route_for decides membership before the row is written:

Verdict Action
Nft written
Fungible / Token dropped (unchanged)
no decisive verdict dropped and logged, one line per contract per ledger

backfill-runner nft-reclassify is deleted along with both INSERT streams and
both row structs. Verdict source is unchanged: soroban_contracts.contract_type.

The quarantine tables are NOT dropped. Nothing writes to them any more, but
they hold 274 + 492 rows from 66 contracts — and ~28 of those are real NFT
collections by their own WASM interfaces. Dropping them today would delete
181 + 183 rows of genuine NFT data against a recovery job that does not
exist. That DROP belongs to the classifier follow-up (0309).

Why it needed doing at all — and why it is smaller than it looks

The acute failure this task was opened for (33 days of a frozen NFT surface,
~6,575 rows/day) was already fixed by PR #341, which is step 2 of this same
task. The quarantine has received nothing since 2026-07-16. What remained was a
dead mechanism whose only drain was a human, plus the fact that it never worked
as advertised:

An NFT must pass two gates — the parser (does this event look like an NFT
operation?) and the classifier (is this contract an NFT?). The quarantine sat at
the second only. Measured: 19 contracts carry an Nft verdict, emit 622 real
events, and have no rows anywhere.
A safety net that catches half the falls is
a place where things wait forever, not a safety net.

Deliberately not in this PR

  • The classifier. A signature rule (transfer's last parameter: token_id
    → NFT, amount → fungible) was written and validated over the whole mainnet
    population — 127,221 contracts, zero contradictions with existing decisive
    verdicts, 48 Other→Nft, 82 Other→Fungible. Task 0415 showed the
    classifier is refuted at the head of the traffic distribution (a price oracle
    exporting decimals is stored as Fungible; 4,211 rows carry that type) and
    that the fix is SEP-0048 typed signature sets. Shipping a partial rule here
    would bury that. Measurements carried to 0415.
  • Reading ownership from ledger state. Rejected on protocol grounds,
    verified against the sources: SEP-0050 is Draft with no storage layout;
    CAP-0046-05 (Final) forbids keyspace iteration; CAP-0046-12 (Final)
    archives persistent entries. Mainnet probing confirmed it empirically — the
    OpenZeppelin Owner(u32) key resolved for 6/7 sampled tokens of standard
    collections and 0 of the 19 broken ones.

Verification

  • Full workspace green, clippy 0, fmt + prettier clean.
  • Routing e2e against a real ClickHouse 26.3 asserts all three arms, verified
    red first
    : making the unclassified arm behave like Keep fails it.
  • npx nx run @rumblefish/api-types:generate → empty diff.
  • The API is untouched: git diff origin/develop -- crates/api is +64 lines,
    0 deletions — one added test. All the behaviour lives in the writer.

Measured, and carried to 0415 rather than acted on here

Finding Scale
NFT ownership disagrees with the chain 4 of 14 sampled tokens, one collection, real owner is a contract
Parser blind to non-standard event names 19 collections, 622 events
Classifier refuted at head of traffic 4,211 rows typed Fungible
nfts is a hand-maintained view of nft_ownership 100% on minted_at_ledger, 13,042/13,051 on owner (9 = burns)
Stale verdict stamps ~73 contracts, one contract-type-rebuild clears them

Deploy

No API deploy, no DROP TABLE, no migration, no point of no return.

  1. Deploy the indexer.
  2. Watch 0392 unclassified NFT-shaped emitter — that log is the list of
    contracts the classifier still cannot name.
  3. Optionally run contract-type-rebuild once (indexer stopped) to clear the
    ~73 stale stamps. Independent and non-blocking.

karolko9 added 6 commits July 22, 2026 09:03
…arantine

`nfts_pending` / `nft_ownership_pending` encoded a mutable judgement — "is
this contract an NFT?" — in an immutable place: which table the row sits in.
ClickHouse has no per-row UPDATE, so the judgement changing forces the row to
move, and something must own the moving. Nothing did: promotion was specified
against Postgres (retired in 0244) and never reimplemented, leaving a manual
`backfill-runner nft-reclassify` run as the only drain and the hot NFT surface
33 days stale.

Rows now go to `nfts` / `nft_ownership` unless the contract is *proven*
fungible, and `NFT_VISIBLE` filters on the contract's current verdict at read
time. A contract classified later surfaces its existing rows on the next read
— nothing promotes, because nothing moves.

The discard rule is unchanged (`Fungible`/`Token` only), so no candidate that
used to be stored is lost; only the destination of the undecided bucket moved.
Enrichment backfill is scoped to visible NFTs so it does not spend external
HTTP/IPFS fetches on rows nobody can see.

`tests/nft_visibility_guard.rs` fails the build if a query reads either table
without the predicate — verified red on each of the four call sites
independently, after fixing a false negative where its search window reached
into the next query.

BREAKING CHANGE: `nfts_pending` and `nft_ownership_pending` are removed from
the schema and `backfill-runner nft-reclassify` is gone. Existing rows must be
merged into `nfts` / `nft_ownership` before the tables are dropped; see
ADR 0053 § Operational Impact for the deploy order.
ADR 0053 records why the quarantine went away rather than getting an automatic
promotion job, with the measurements the decision rests on: read cost of the
predicate (42 ms / 239k rows vs 24 ms / 49k baseline on prod), the cost of
invisible rows (5M of them add 13 ms — the PK prefix prunes them), and the four
rejected alternatives including the CH view that would have shadowed the table
name.

ADR 0046 is marked superseded rather than deleted: its Context and Alternatives
remain the authority on *why* unclassified rows must not reach the API, and why
the parser cannot make that call. What died with it is the Postgres-shaped
promotion mechanism.

Architecture docs updated per ADR 0032. The 0118 / 0217 / 0221 / 0294 runbooks
get a RETIRED banner instead of deletion — they record operations that really
ran on prod; left unmarked they would be a trap, deleted they would lose the
history.
…to 0259/0317/0325

0392's plan aimed at the symptom — a continuous promote/drop. Building it showed
the trigger would never fire for the population that is actually stuck, so the
plan, the acceptance criteria and the summary now describe removing the split
instead, with the emerged decisions recorded separately from the planned ones.

Measured while verifying, and parked where it belongs:

- 0317 — 122 contracts carry an `Nft` verdict but only 66 have any rows. Of the
  56 with none, 19 emit real events (622 total: `mint` 46, `uri_upd` 58,
  `minted` 5, `identity_minted` 5, `transfer` 3). Those rows were never created:
  the parser only considers `transfer`/`mint`/`burn`/`consecutive_mint` and
  skips every other symbol silently. Classifier work cannot surface a row that
  does not exist.
- 0325 — `build_wasm_upgrade_rows` carries `contract_type` forward on upgrade,
  so a class flip is overwritten with the pre-upgrade verdict; the G9 cache also
  never re-resolves a decisive verdict for the container's lifetime. Scope
  shrinks to fixing the verdict itself — there is no quarantine left to promote
  from.
- 0259 — the blocker it names is gone; the canonical tables now hold data, so
  the E15/E16/E17 parity run is finally possible.
Replaces the read-time visibility filter this branch shipped two commits ago.
Review rejected it on the right grounds: if a row is in `nfts`, it should be an
NFT — not a maybe-NFT that every reader has to remember to filter.

So the decision moves to the writer. `route_for` keeps a row only when the
contract's verdict is `Nft`; `Fungible`/`Token` drop as they always did; a
contract with no decisive verdict is dropped AND logged, one line per contract
per ledger. Dropping is the only outcome that can lose a real collection, so it
is the one that is never silent — that log doubles as the classifier's work
queue.

The verdict lookup now fails closed. Routing that discards the unclassifiable
must not read a ClickHouse hiccup as "unclassifiable"; the retry envelope and
S3 redelivery re-attempt the ledger instead. The old fail-open behaviour was
only ever safe because a quarantine existed to fall through into.

Verdict source is unchanged (`soroban_contracts.contract_type`). Re-deriving it
from `wasm_interface_metadata` per lookup was built and measured, then dropped:
that column is meant to mirror the contract's WASM, so the answer to it going
stale is to refresh it, not to route around it. Cost accepted and measured —
~73 contracts carry a stale `Other` despite a decisive WASM, which one
`contract-type-rebuild` pass clears.

`nfts_pending` / `nft_ownership_pending` stop being fed but are NOT dropped.
They hold 274 + 492 rows from 66 contracts, ~28 of which are real NFT
collections by their own interfaces; deleting them now would destroy 181 + 183
rows of genuine NFT data against a recovery job that does not exist. The DROP
belongs to the classifier follow-up (0309). Both row structs, both INSERT
streams and `nft-reclassify` are gone — nothing writes there any more.

The routing e2e proves all three arms against a real ClickHouse and was verified
red first: making the unclassified arm behave like `Keep` fails it.

BREAKING CHANGE: `backfill-runner nft-reclassify` is removed. Its promote/drop
no longer exists as an operation. `contract-type-rebuild` takes on the job of
making a classifier improvement take effect, since routing reads the stamped
verdict.
…e the shovels

ADR 0053 was written for the read-time filter and then for a live WASM
classification; both were built, measured and rejected. It now records what
actually ships, and — more usefully — what it deliberately does not:

- the classifier stays untouched. A signature rule (`transfer`'s last parameter)
  was validated on 127,221 contracts with zero contradictions and would resolve
  48 → Nft / 82 → Fungible, but 0415 showed the classifier is refuted at the head
  of the traffic distribution, so a partial patch would bury that finding;
- the quarantine tables are not dropped, because ~28 of their 66 contracts are
  real NFT collections and the recovery job does not exist yet;
- reading ownership from ledger state is rejected on protocol grounds, verified
  against the sources: SEP-0050 Draft (no storage layout), CAP-0046-05 Final
  (no keyspace iteration), CAP-0046-12 Final (persistent entries archive).

The four measured findings are ranked in the ADR so the reader can see this
change is the smallest of them; the other three belong to 0415.

Docs follow the same correction, and `contract-type-rebuild` gets its role
restated: no longer "run before nft-reclassify" but "what makes a classifier
change take effect". Stale docstrings the shovel audit surfaced are fixed —
repair-tier1 repairs 5 columns across 4 tables, not 6 across 5.
…nts to 0415

0392's headline was stale: the acute failure — 33 days of a frozen NFT surface —
was fixed by PR #341, which is step 2 of this same task, before this work
started. The quarantine has taken nothing since 2026-07-16. What remained was a
dead mechanism and its manual shovel, and the task now says so.

Everything larger that turned up goes to 0415, where the events-vs-ledger
question already lives, with the numbers attached:

- NFT ownership disagrees with the chain — 4 of 14 sampled tokens, all in one
  collection, including tokens whose real owner is a contract. First direct
  chain comparison of that defect;
- reading ownership from ledger state cannot replace events — 6/7 standard
  tokens resolve under the OpenZeppelin key, but 0 of the 19 broken collections
  do, because storage keys are as author-defined as event names;
- the four protocol claims 0415 rests on, re-verified against CAP-0046-05,
  CAP-0046-12, SEP-0050 and CAP-0046-08 with their Status fields;
- the classifier candidate rule with its full population cross-tab;
- `nfts` reproduces from `nft_ownership` at 100% on minted_at_ledger and
  13,042/13,051 on owner — the 9 misses are burns — so it is a hand-maintained
  materialisation, and repair-tier1 partly repairs a derivable column;
- G1 liveness by deploy era: ~73 contracts carry a stale `Other`, one rebuild
  pass clears them.

Diagrams under notes/ redrawn for what the session established: the two gates an
NFT must pass, the before/after of where the decision happens, and fact vs
conclusion. Two diagrams from the rejected read-time-filter design were retired.
@karolko9 karolko9 changed the title fix(0392): decide NFT visibility at read time, drop the nfts_pending quarantine fix(0392): decide NFT membership before the write, retire nft-reclassify Jul 22, 2026
They were working artefacts for one conversation, not durable context — the
measurements they illustrate live in the ADR and in 0415, in text that can be
grepped and diffed. Moved to .trash/.
Base automatically changed from develop to master July 25, 2026 09:38
stkrolikiewicz and others added 2 commits August 13, 2026 00:54
release: LP per-operation amounts (lore-0279) + prices_writer grants (lore-0477)
@karolko9
karolko9 changed the base branch from master to develop August 14, 2026 12:52
@karolko9 karolko9 closed this Aug 21, 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.

2 participants