fix(subnets): give ownership-history the source that actually has rows - #9321
Merged
Conversation
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
metagraphed-registry-sync-api | c6fbb94 | Aug 03 2026, 09:56 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
metagraphed-data-api | c6fbb94 | Aug 03 2026, 09:56 PM |
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
metagraphed-ui | c6fbb94 | Aug 03 2026, 09:59 PM |
/api/v1/subnets/{netuid}/ownership-history answered an empty list for 127 of
the 128 subnets the poller has ever watched, and it was not a missing reader:
the lakehouse cold tier landed in #9198 and serves. The reader is complete with
respect to the stream it reads, and that stream is nearly empty. Measured
2026-08-03 against the whole 895M-row chain.chain_events table:
SELECT method, count(*) FROM chain.chain_events
WHERE pallet = 'SubtensorModule' AND method = 'SubnetOwnerChanged'
-> 1
One event, chain-wide (netuid 18, block 8,724,813). The permissionless
conviction contest is only one of the ways a subnet's owner can change, and the
others leave no event of that method behind.
THE SECOND SOURCE. chain.subnet_ownership_history is the poller's own record of
who it observed owning each subnet, appended only when the observed owner
changes (deploy/postgres/schema.sql documents the writer). It holds 135 rows
across 128 netuids, and three of those subnets changed owning account between
consecutive captures -- 18, 86 and 102. Only 18 has a chain event. So 86 and
102 are transfers that provably happened and that the route published as "this
subnet has never changed hands", in the same words a subnet that really never
had would get.
Both sources now feed the SAME buildSubnetOwnershipHistory formatter, deduped
on the (old, new) account pair with the event winning: for netuid 18 the
decoded event's old/new coldkeys equal the ledger's consecutive owners
(verified), so publishing both would read as two flips. A first-ever
observation is NOT a transfer -- it records when tracking began, and 128
subnets have exactly that one row, so treating them as transfers would invent
128 flips that never occurred.
LABELLED, BECAUSE THE TWO MEAN DIFFERENT THINGS. Each record carries a source:
a chain-event record's observed_at is when the chain announced the transfer and
carries the block that did it, while an owner-observation record's is when the
poller NOTICED -- an upper bound, with no block behind it. A caller that cannot
tell them apart would read a capture lag as a transfer time. observed_through
publishes how far the observation source covers the subnet at all, so "watched,
never changed hands" stops being indistinguishable from "not watched since"
(the ledger is frozen at 2026-08-01; a refresh lane is separate work).
ONE COMPOSER, NOT THREE RESHAPES. All three surfaces already reached the same
reader and then each rebuilt the answer from it by hand -- MCP's
narrowOwnershipHistory kept four fields, GraphQL's resolver kept the same four
separately, REST forwarded the payload whole. They agreed only by coincidence,
and source/observed_through would have reached REST alone. That is #9285 and
src/subnet-ownership-answer.ts and the surfaces stop deciding.
tests/subnet-ownership-surface-parity.test.ts pins both halves: structurally, no
surface imports a tier reader or projects the payload; behaviourally, one set of
store responses yields the same records, labels and coverage on all three.
R2 SQL: the ledger read narrows by netuid in SQL (unlike the event stream,
whose args are an opaque JSON string with no netuid predicate expressible), the
netuid goes through safeBlockNumber since there are no bound parameters, and
there is no count(DISTINCT) anywhere. Either leg failing declines the whole
read -- half a two-source history is a wrong answer wearing the shape of a
complete one.
/subnets/{netuid}/conviction is deliberately untouched: there is no
chain.subnet_conviction table at all, so it needs an owner decision (derive it
from a source that exists, or retire the route in the contract), not a reader.
Closes #9312
JSONbored
force-pushed
the
fix/ownership-history-second-source
branch
from
August 3, 2026 21:54
95d58a2 to
c6fbb94
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
/api/v1/subnets/{netuid}/ownership-historyanswered an empty list for 127 of the 128 subnets the poller has ever watched. That was not a missing reader — the lakehouse cold tier landed in #9198 and serves (meta.source: "lakehouse-cold-tier"in production right now). The reader is complete with respect to the stream it reads, and that stream is nearly empty. Measured 2026-08-03 against the whole 895M-row event table:One
SubnetOwnerChangedevent chain-wide (netuid 18, block 8,724,813). The permissionless conviction contest is only one of the ways a subnet's owner can change; the others leave no event of that method behind.This PR gives the route the source that actually has rows, labels the two apart, and moves the payload's assembly out of the three surfaces that were each doing it themselves.
What Changed
The second source.
chain.subnet_ownership_historyis the poller's own record of who it observed owning each subnet, appended only when the observed owner changes (deploy/postgres/schema.sqldocuments the writer). 135 rows across 128 netuids; three subnets changed owning account between consecutive captures — 18, 86 and 102 — and only 18 has a chain event. So 86 and 102 are transfers that provably happened and that the route published as "never changed hands", in the same words a subnet that really never had would get.src/subnet-ownership-cold-tier.tsreads the ledger per netuid and both sources feed the samebuildSubnetOwnershipHistoryformatter.Deduped, event first. For netuid 18 the decoded event's old/new coldkeys equal the ledger's consecutive owners (verified by running the shared decoder over the real args), so publishing both would read as two flips. Dedup is on the
(old, new)account pair.A first-ever observation is not a transfer. It records when tracking began. 128 subnets have exactly that one row — treating them as transfers would invent 128 flips that never occurred. They contribute
observed_throughand nothing else.Labelled, because the two mean different things. Each record carries
source:sourceobserved_atmeansblock_numberchain-eventowner-observationnullA caller that cannot tell them apart would read a capture lag as a transfer time.
The capture stamp.
observed_throughis the newest owner observation for the subnet, so "watched, never changed hands" stops being indistinguishable from "not watched since". The ledger is frozen at 2026-08-01; building a refresh lane is deliberately out of scope (the poller lives in a private repo), but publishing a stale answer as current is this route's problem and is fixed here — the discipline #9273 and #9296 established.One composer, not three reshapes. All three surfaces already reached the same reader and then each rebuilt the answer by hand: MCP's
narrowOwnershipHistorykept four fields, GraphQL's resolver kept the same four separately, REST forwarded the payload whole. They agreed only by coincidence, andsource/observed_throughwould have reached REST alone — #9285 and #9296's shape a third time. The cascade and the field mapping now live insrc/subnet-ownership-answer.ts; the surfaces stop deciding.tests/subnet-ownership-surface-parity.test.tspins both halves: structurally, no surface imports a tier reader or projects the payload (read from source, not from behaviour); behaviourally, one set of store responses yields the same records, labels and coverage on REST, MCP and GraphQL.R2 SQL constraints. The ledger read narrows by
netuidin SQL (unlike the event stream, whoseargsis an opaque JSON string with no netuid predicate expressible at all); the netuid goes throughsafeBlockNumberbecause there are no bound parameters; there is nocount(DISTINCT)anywhere. Either leg failing declines the whole read — half a two-source history is a wrong answer wearing the shape of a complete one.Live verification (not "tests pass")
answerSubnetOwnershipHistoryrun against the real warehouse, for the exact netuids the issue reported as empty plus the three that changed hands:Every line is the intended behaviour, including the zeros:
ownership_changes: []before this PR and are real transfers.count: 0, but now carryobserved_through— the poller watched them and saw no transfer, which is a different statement from the empty they used to return.observed_through: null: it has no owning account and no ledger row, so nothing is claimed about it.Scope notes
main. feat(accounts): compute the per-day activity series from the lakehouse #9317 landed while this was in flight and computes the per-day series live fromchain.account_events. That half of fix(api): account history and subnet ownership-history serve a permanent empty while their rows sit in the lakehouse #9312 is done, and better than reading the frozenaccount_events_dailyrollup would have been; nothing here touches it./subnets/{netuid}/convictionis deliberately untouched, and is also already fixed onmain. There is nochain.subnet_convictiontable at all (the catalog returns "absent"), so it never needed a lakehouse reader — feat(subnets): serve conviction live from chain storage #9320 landed while this was in flight and serves it live from chain storage. Nothing here touches it; the rebase onto that commit keeps itscoldTierChainEventsPayloadconviction branch and only moves the ownership-history branch onto the composer.Registry Safety
Closes #<n>) — required.npm run build+node scripts/generate-openapi-docs.tsfromapps/ui).r2-manifest.json/schemas/index.jsonauto-reverted).source,observed_through, and the SDL fields GraphQL was silently dropping).Validation
npm run lint·npm run format:check·npm run typechecknpm run validatenpm run validate:schemasnpm run validate:openapinpm run validate:typesnpm run validate:contract-driftnpm run validate:mcp(210 tools)npm run validate:artifact-budgetsnpm run validate:docsnpm run validate:intakenpm run validate:private-boundarynpm run test:coverage— 661 files, 15,376 tests, all passingnpm run scan:public-safety— passedgit diff --checkPatch coverage, by intersecting the diff's added lines with v8's uncovered set (a whole-file percentage answers the wrong question):
(Recomputed after rebasing onto #9320; whole-repo numbers from the same run: statements 99.20%, branches 98.08%.)
Closes #9312