Skip to content

fix(subnets): give ownership-history the source that actually has rows - #9321

Merged
JSONbored merged 1 commit into
mainfrom
fix/ownership-history-second-source
Aug 3, 2026
Merged

fix(subnets): give ownership-history the source that actually has rows#9321
JSONbored merged 1 commit into
mainfrom
fix/ownership-history-second-source

Conversation

@JSONbored

@JSONbored JSONbored commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Summary

/api/v1/subnets/{netuid}/ownership-history answered 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:

SELECT method, count(*) FROM chain.chain_events
 WHERE pallet = 'SubtensorModule' AND method = 'SubnetOwnerChanged' GROUP BY method
-- [{"method":"SubnetOwnerChanged","n":1}]

One SubnetOwnerChanged 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; 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_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). 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.ts reads the ledger per netuid and both sources feed the same buildSubnetOwnershipHistory formatter.

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_through and nothing else.

Labelled, because the two mean different things. Each record carries source:

source observed_at means block_number
chain-event when the chain announced the transfer the block that emitted it
owner-observation when the poller noticed (an upper bound) null

A caller that cannot tell them apart would read a capture lag as a transfer time.

The capture stamp. observed_through is 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 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 — #9285 and #9296's shape a third time. The cascade and the field mapping now live in src/subnet-ownership-answer.ts; 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 (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 netuid in SQL (unlike the event stream, whose args is an opaque JSON string with no netuid predicate expressible at all); the netuid goes through safeBlockNumber because there are no bound parameters; 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.

Live verification (not "tests pass")

answerSubnetOwnershipHistory run against the real warehouse, for the exact netuids the issue reported as empty plus the three that changed hands:

netuid   0: count=0 observed_through=null                      sources=[]
netuid   1: count=0 observed_through=2026-07-20T08:46:40.378Z  sources=[]
netuid   4: count=0 observed_through=2026-07-20T08:46:40.378Z  sources=[]
netuid   8: count=0 observed_through=2026-07-20T08:46:40.378Z  sources=[]
netuid  18: count=1 observed_through=2026-07-29T03:05:51.987Z  sources=["chain-event"]
netuid  64: count=0 observed_through=2026-07-20T08:46:40.378Z  sources=[]
netuid  86: count=1 observed_through=2026-07-24T17:55:20.256Z  sources=["owner-observation"]
netuid 102: count=1 observed_through=2026-07-21T23:20:19.105Z  sources=["owner-observation"]
netuid  27: count=0 observed_through=2026-07-22T13:15:19.143Z  sources=[]

Every line is the intended behaviour, including the zeros:

  • 18 publishes ONE record from the chain event, not two — the ledger's matching pair was deduped away, and the surviving record keeps its block.
  • 86 and 102 were ownership_changes: [] before this PR and are real transfers.
  • 1, 4, 8, 64, 27 are still count: 0, but now carry observed_through — the poller watched them and saw no transfer, which is a different statement from the empty they used to return.
  • 0 (root) has observed_through: null: it has no owning account and no ledger row, so nothing is claimed about it.

Scope notes

Registry Safety

  • Links a tracked, currently-open issue (Closes #<n>) — required.
  • No secrets, PATs, wallet data, private dashboards, private URLs, or validator-local state.
  • Generated artifacts were produced by repo scripts, not hand-edited (npm run build + node scripts/generate-openapi-docs.ts from apps/ui).
  • R2-only/high-churn detail artifacts are not committed (r2-manifest.json / schemas/index.json auto-reverted).
  • Public API/OpenAPI/schema changes are intentional and documented (source, observed_through, and the SDL fields GraphQL was silently dropping).

Validation

  • npm run lint · npm run format:check · npm run typecheck
  • npm run validate
  • npm run validate:schemas
  • npm run validate:openapi
  • npm run validate:types
  • npm run validate:contract-drift
  • npm run validate:mcp (210 tools)
  • npm run validate:artifact-budgets
  • npm run validate:docs
  • npm run validate:intake
  • npm run validate:private-boundary
  • npm run test:coverage — 661 files, 15,376 tests, all passing
  • npm run scan:public-safety — passed
  • git diff --check

Patch coverage, by intersecting the diff's added lines with v8's uncovered set (a whole-file percentage answers the wrong question):

src/chain-events-degraded.ts     added 5    uncovered lines [] branch arms []
src/contracts.ts                 added 2    uncovered lines [] branch arms []
src/graphql-sdl.ts               added 7    uncovered lines [] branch arms []
src/graphql.ts                   added 5    uncovered lines [] branch arms []
src/mcp-server.ts                added 21   uncovered lines [] branch arms []
src/subnet-ownership-answer.ts   added 86   uncovered lines [] branch arms []
src/subnet-ownership-cold-tier.ts added 49  uncovered lines [] branch arms []
src/subnet-ownership-history.ts  added 116  uncovered lines [] branch arms []

TOTAL 293 added lines in src/**+workers/**  ->  0 uncovered lines, 0 uncovered branch arms

(Recomputed after rebasing onto #9320; whole-repo numbers from the same run: statements 99.20%, branches 98.08%.)

Closes #9312

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
metagraphed-registry-sync-api c6fbb94 Aug 03 2026, 09:56 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
metagraphed-data-api c6fbb94 Aug 03 2026, 09:56 PM

@superagent-security

Copy link
Copy Markdown

Superagent didn't find any vulnerabilities or security issues in this PR.

@github-actions
github-actions Bot deployed to preview/pr-9321 August 3, 2026 21:45 Active
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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
JSONbored force-pushed the fix/ownership-history-second-source branch from 95d58a2 to c6fbb94 Compare August 3, 2026 21:54
@github-actions
github-actions Bot deployed to preview/pr-9321 August 3, 2026 21:57 Active
@JSONbored
JSONbored merged commit 3664fe3 into main Aug 3, 2026
10 checks passed
@JSONbored
JSONbored deleted the fix/ownership-history-second-source branch August 3, 2026 22:04
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.

fix(api): account history and subnet ownership-history serve a permanent empty while their rows sit in the lakehouse

1 participant