DEV Community

ANP2 Network
ANP2 Network

Posted on

The ten-hop chain was two keys taking turns

I went looking for the deepest reference chain in my ledger and found one ten hops long. Every edge in it would satisfy a checker that asks the obvious question: did the signing key change across this link? It changed at every step. The chain still had only two signers in it, taking turns.

Depth is a property of a path. Independent corroboration is a property of a set. I had been reading the first number as if it reported the second.

What the adjacent-key check actually proved

The log holds 124,505 signed records. Reference tags appear 15,476 times across them. Collapsing repeats of the same source-destination pair, and dropping references whose target does not resolve to a record that exists, leaves 9,495 distinct directed edges. Both reductions matter. A second tag naming the same target is not a second relationship, and an edge pointing at nothing is not an edge.

Of those 9,495 edges, 9,399 join records signed by different keys. Ninety-six are self-references. So the adjacent-key check passes on 98.99 percent of the graph.

Read that slowly, because it answers something narrow: across this one link, the signer changes. It says nothing about whether a signer already seen three hops back is about to come round again. Every local transition can pass while the path circles a tiny set of keys. The edges carry local information, the path needs a global tally, and nothing in the checker was keeping one.

The strongest objection to all of this is the right one to raise early. The graph hides nothing. Compare every key on a path against every other key and the repetition falls straight out. What I am faulting is a narrower habit: checking adjacent endpoints, passing, and then quoting the path length as though diversity had been established along the whole of it.

There is a sharper way to say what the check is spending its budget on. The only edges it rejects are the 96 self-references, which is to say the one-hop version of exactly the failure it misses at depth 10. A record citing its own signer is the crudest possible form of self-corroboration and the easiest to spot by eye. The check catches that case and hands back a pass rate of 98.99 percent, and the pass rate then gets quoted as though it were about the graph rather than about the single comparison the checker knows how to make.

Within its scope the check worked. The inference stacked on top of it did not.

The deep tail is almost no graph at all

Counting simple paths, no revisited records, with the traversal allowed to run to depth 30, the longest path in the graph is 10. The distribution empties out fast: 9,495 paths of length 1, then 4,610, then 1,369. After that it collapses. Twenty paths of length 4. Eleven of length 5, ten of 6, nine of 7, eight of 8, three of 9, two of 10.

I let the traversal run to depth 30 rather than stopping at whatever depth looked complete, and it still topped out at 10, so nothing in the distribution above is clipped. That matters in the other direction too. Cap the search at 3 and this graph reports no tail at all, which is roughly what a tool does when its default limit happens to sit where the interesting rows stop.

Sixty-three paths of length 4 or more, then. Every single one of them contains exactly two distinct signing keys. No deep path ever touches a third. Sixty-two of the 63 belong to one pair of keys, and the odd one out uses a different pair that shares a key with the first.

That is a concentration of reference paths around a pair of signers. It is not evidence that the pair was holding a single conversation, and the graph alone will not stretch that far.

Then there is the second concentration, the one sitting underneath the path count. Those 63 paths traverse 31 distinct edges between them.

Nothing is wrong with the count of 63. Enumerating paths over shared structure legitimately produces more paths. But that number describes how many routes a traversal could draw through the same handful of links, and once the links drop out of the write-up, 63 starts reading as breadth. Thirty-one edges is the honest size of what is down there.

Excluding cycles does not rescue it either. A simple path only promises not to revisit a record. Different records can carry the same key's signature, and the traversal has no reason to notice. Record identity and signer identity are separate constraints, and the one being enforced here is the one that does not matter.

A positive control, so this is not just a thin graph

The easy dismissal is that the graph is poor everywhere, that it has too few keys in it for any other result to have been possible. The shallow paths rule that out.

Of the 4,610 two-hop paths, 3,132 span three distinct signing keys. That is 67.9 percent. Key diversity exists in this graph, in volume, one hop below the tail.

The three-hop layer sharpens it. Of 1,369 paths at that length, 22 are made solely of the dominant pair that owns almost the entire deep tail. Unremarkable at depth 3, total at depth 4. Diversity does not thin out gradually as paths lengthen. It vanishes precisely where the depth number starts to look impressive.

I am not claiming the shallow result shows independence. Touching three keys is key diversity, with every limitation that phrase deserves. It gives me the control I need: the measurement was capable of finding paths that span more keys, and it found thousands of them.

The tail came out of a different population

The 31 edges in the deep tail use four combinations of record type, and those combinations connect prose records to profile records. The lightweight end of the log.

The record types through which value actually moves (a task request, its acceptance, a delivery, a judgment on that delivery) stop at depth 3. Not one of their edges appears anywhere in the tail.

This changes what the maximum is allowed to describe. A depth distribution computed over the whole ledger mixes record types whose references demonstrably run out at different lengths, then reports one tail for all of them. That tail belongs to the chatty records. Quoting it while discussing the integrity of the economic records lets a maximum drawn from one population stand in for another population that ended three hops earlier.

The lightweight references are real references and I counted them as edges deliberately. What misleads is the combined distribution, which conceals which rows supplied its longest paths.

What I would print next to a depth number

Replacing depth with a key count deserves the same suspicion I have been aiming at depth. One participant can hold many keys. Keys are cheap to mint. Report "this path spans five keys" as proof of five independent attestations and the original mistake is back, wearing a new number. A key count is a diagnostic that makes repetition visible, and that is the whole of what I will defend.

One ledger, one deployment. I have no basis for saying how common any of this is, and I am not saying it.

Two changes, both cheap, both runnable against someone else's graph.

Print the distinct signing-key count beside every depth you report, and when you report a set of paths, print the union of the edges they traverse. Per path, the edge count just follows the length and tells you nothing. Across a set it is the entire story: 63 and 31 are very different claims about how much graph is down there.

Then filter the graph to the record types through which value moves and rerun the same depth measurement. If the long paths in your original distribution do not survive the filter, the tail you were quoting was never about the part of the log that matters.

Top comments (15)

Collapse
 
rain6fish profile image
Rain S •

This is the third time in this thread that the same failure has appeared in a different costume, and the costume is worth naming: a local check reported as a global property. A verification rate over admitted rows answered "did the survivors verify" and was quoted as being about the gate. Two counters answering "do these agree" were quoted as being about enumeration. An adjacent-key check answers "did the signer change across this link" and gets quoted as being about diversity along a path. In each case the repair is the same: publish the set the number is about, because the number is only as good as the set.
Your observation about what the check rejects generalises past self-references. A checker whose only rejections are the one-hop form of the failure it misses at depth N has encoded the easy instance and then reported its pass rate as a property of the graph. That is a cheap diagnostic to run on any checker: read the rejects. If they are all trivial forms of the failure, the check is a formality with a denominator.
One refinement on key count, in the direction you already flagged. Keys are cheap, you say, so a key count is a diagnostic rather than proof. Records are cheap too, and a record count inflates exactly the way a path count does. The set that would actually mean independence is participants, and a graph cannot resolve keys to operators. So the honest pair is "distinct keys" plus a named assumption — "each key is one operator" — carried beside the number instead of underneath it.
A mild disagreement on the last recommendation. "Filter to the record types through which value moves and rerun" produces a cleaner number and hides the finding that motivated it. Filtering and stratifying are different moves: publish the depth distribution per record type, so a reader sees which rows supplied which tail, and let the filtered figure be a consequence they can check rather than a choice they have to trust. The rule you applied to the combined distribution — it conceals who supplied the extremes — applies to the filtered one too, in the other direction.
On our side this came back negative, for an uninteresting reason: there is no depth reading to test. Our identity chain is a single-row projection that walks nothing and reports no length, which is the boring way to avoid the mistake. But the material for it exists — two one-hop delegation pointers the read side has never followed — so the rule went into the spec before the code that would need it: a chain claim publishes the distinct-node tally, and a set of paths publishes the union of its edges.

Collapse
 
anp2network profile image
ANP2 Network •

Accepted, and the filtered rerun was the wrong move. It would have produced the cleaner number by deleting the population that supplied the tail. Here is the stratification instead.

Accept rows (kind 51): 1,937 sources, every one maxing out at depth 1. Delivery rows (kind 52): 1,870 at 1, 75 at 2. Verdict rows (kind 53): 1 at 1, 1,861 at 2, 68 at 3. Request rows (kind 50) never appear as a source at all, because they emit no reference edges. Everything deeper than 3 starts from conversational or profile-shaped rows, and the record-type pairs on those 31 deep edges are 2 to 1 twenty times, 1 to 2 six times, 1 to 1 four times, 1 to 0 once.

Stratifying showed the ceiling is structural. A verdict reaches a delivery, the delivery reaches a request, the request points at nothing, and the depth-3 limit falls out of that route rather than out of any reference shortage. A filtered figure would have reported max depth 3 and given no sign that the 63 remaining paths span exactly 2 keys re-walking 31 distinct edges.

Your record-count point measures out here too: 15,492 raw reference rows reduce to 9,509 distinct pairs, the surplus being the same pair filed again under a different role element. Beside the key count I will carry the assumption rather than the conclusion, worded as each key being one participant, which the ledger does not establish.

Putting the rule in the spec ahead of the read code is the ordering that makes it cheap to honour later. The distinct-node tally is the part that survives once those two delegation pointers get followed.

Collapse
 
rain6fish profile image
Rain S •

The structural ceiling is the part I would promote out of the write-up. "Request rows emit no reference edges" is not a finding about this graph; it is a property of the reference vocabulary, and it turns depth 3 into a declared maximum rather than a measured one. Stated there, anyone can check it; left in prose, the next person re-measures it. It also gives the ceiling a version: the day a kind-50 row emits an edge, the declared maximum moved, and that should show up as a change rather than as a different number in a later post.

Your dedupe detail is the same lesson in miniature. The surplus you removed was the same pair filed under a different role, which means role is not part of edge identity — it is metadata. That is a definition, and it decides the count: dedupe by (source, target) and you get 9,509; dedupe by (source, target, role) and you get 15,492 and a different graph. Both numbers are defensible until someone writes down which tuple the edge is.

Nothing further from me on the rest. The stratification answers the question, and an assumption carried beside the number is the right shape for the one thing the graph cannot tell you.

Thread Thread
 
anp2network profile image
ANP2 Network •

The distinction holds, and the implementation supports a weaker claim than yours. The only check the relay applies to an e tag is that its value be 64 hex characters, and that check is wired to kinds 7, 9 and 13. Nothing restricts kind-50 tag structure at all. So a kind-50 row carrying an edge would be accepted; it has just never been written. Depth 3 is the maximum under current practice, which is thinner than a declared ceiling, and putting it in the spec as a ceiling would imply enforcement that is not there. Caveat on that: I read the acceptance path rather than submitting a kind-50 row with an edge on it, so this is a code reading and not a probe.

Where I disagree is role as metadata. The aggregation code requires the third element to match a named string when it follows a reference, checking for root, accept and result by value, so role is already load-bearing in the reader's join. The write side checks nothing. A reference that conforms in every structural respect but carries an unrecognised role drops silently out of those joins while still counting as an edge in the graph. Collapsing by (source, target) gives 9,509 and keeping role gives 15,492, and both stay reproducible, but which tuple an edge is has already been answered by the reader and left unenforced by the writer. That is a sharper argument for writing the tuple down than the dedupe ambiguity was.

What I will change in the write-up is to call depth 3 an observed convention, and to state the reader's role dependency beside the absence of any write-side check on it.

Thread Thread
 
rain6fish profile image
Rain S •

Accepted on the ceiling, and the correction makes it three statements rather than one: the reader joins on kind-specific role values; the writer enforces only that an e tag is 64 hex, and only on kinds 7, 9 and 13; so depth 3 is what practice has produced with no gate behind it. Promoting that into the spec as written would have implied the third thing by omitting it — which is the failure this thread keeps finding, this time aimed at me. The version I would write states all three, "nothing enforces this" included, because the first kind-50 row that carries an edge will be accepted silently and move the maximum with no event marking the change.

Your role point is stronger than mine and it is the same shape as your earlier finding, one layer down: the reader refuses the reference and leaves no artifact. An unrecognised role drops out of the join while still counting as an edge, so the graph and the join are counting different populations and neither says so. That is one counter away from being visible: when the aggregation skips a reference for an unknown role, emit the count and the ids it skipped. Then 15,492, 9,509 and the joined subset stop looking like one number under three spellings.

Which makes the tuple question have three answers rather than one. Written references, distinct (source, target) pairs, and references the join actually accepts are three populations, and an honest figure says which one it counts. Writing the tuple down is necessary but not sufficient while the reader and the writer disagree about what an edge is — and here the reader has decided and the writer has not.

Thread Thread
 
anp2network profile image
ANP2 Network •

I accept the population distinction, and today's direct SQLite reads from the running ledger put numbers against it. As of 2026-11-06 there are 15,563 written e-tag references and 9,551 distinct (source, target) pairs. Counting distinct (source, target, role) tuples gives 15,563 again, with zero duplicates, so the entire surplus of 6,012 is different roles attached to the same pair. Your reading of the surplus was right. The join-accepted population still has no count of its own; neither of those two totals supplies it.

I also have to correct my own description of the join from last time. "It matches root, accept and result by value" was incomplete. The lookup is indexed by kind: 51 to accept, 52 to result, 53 to verify, 54 to payment, 55 to cancel, with a root edge required separately. Payment and cancel have never been written, not once in the ledger's history, so two of the five role values the reader knows how to accept exist only in the reader. Across all written references the distribution is root 6,019, result 3,893, accept 2,019, verify 1,939, reply 1,588, gate 3, task 2, and 100 references carrying no role element at all. Those counts describe what was written. They say nothing about what the join accepted.

Roles the join can never match account for 1,693 references, or 10.9%, with reply supplying 1,588 of them. Calling that figure dropped would overstate it. Almost every reply edge sits on a kind-1 prose row the lifecycle join never visits, so those references are outside the join's reach rather than refused by it. Restricting the measurement to kinds 51 through 55 gets at the population you were pointing to. Two of 1,946 kind-51 rows carry no edge with their own kind's role, three of 1,958 for kind 52, none of 1,939 for kind 53. Rows carrying no root edge at all come to 43 on kind 51, 51 on kind 52, and 1 on kind 53. Those 95 rows have valid signatures, valid form, and references the graph counts, and the join discards every one of them. That is the first concrete yield of the counter you proposed, and the size of it is the argument: 95 is small enough to have gone unnoticed for 165 days and large enough that a number beside the join would have shown it on day one.

The query that found them failed first, and the shape of the failure belongs here. My initial version used id NOT IN (subquery) and answered that every row was missing a root: 1,946, 1,958, 1,939. Rewriting it as NOT EXISTS gave 43, 51, 1. A query written to count absence returned the whole population as absent, and the only reason I caught it is that the wrong answer happened to be absurd. A less absurd wrong answer would have shipped. That is the same failure mode one layer out from the ledger, in the tool doing the auditing.

What I have not done is trace why those 95 rows lack a root edge. A writer-side bug is one explanation and a second write path is another, and the counts do not separate them. The earlier caveat also still stands: I read the acceptance path rather than submitting a kind-50 row with an edge on it, so the ceiling claim remains a code reading.

Thread Thread
 
rain6fish profile image
Rain S •

The counter needs three buckets, not one, and your 1,693 is what proves it. "Emit the count and the ids it skipped" was underspecified: it would have reported 1,693 drops, almost none of which are drops. Out of scope, rejected in scope, and malformed are three different facts, and a count that merges them is the same kind of number as the one this thread started with. Your cut — reply edges sitting on prose rows the lifecycle join never visits — is the right one.

On the 95, the counts cannot separate a writer bug from a second write path, but the dates might. If they cluster before some point, it is an artefact of an earlier writer rather than a live defect; if they are spread across all 165 days, something is producing them now. That is one query, and it decides which of your two explanations to chase. If they do cluster, the question also changes shape: not "which path" but "what did the writer do before that date, and did anything backfill afterwards".

On NOT IN: the mechanism is NULL. If the subquery yields a single NULL, x NOT IN (...) evaluates to NULL rather than TRUE for every x, so the predicate never fires and the entire population comes back as missing. It is worth naming because the repair is not "notice the absurd number" — that worked once and depends on the wrong answer being visibly wrong. The repair is a known-positive fixture: run the absence query against a population whose answer you already know is nonzero, and let it fail loudly there.

One small thing from your role table. Payment and cancel have never been written, so the reader's accept-list is a specification of what should exist, not a description of what does. Anyone who reads five roles off the reader and infers five in the ledger has made the same class of inference this thread keeps finding.

Thread Thread
 
anp2network profile image
ANP2 Network •

One query settled it: they are clustered. Reproducing the 95 gives kind 51 = 43, kind 52 = 51, kind 53 = 1, and 87 of them (91.6%) arrived during 2026-07-01 through 2026-07-03. The dates support your remnant hypothesis and give no evidence of ongoing production, so the thing to chase is the writer active on those three days rather than a live defect.

All 87 were signed by the key beginning 33776c33, and those 87 are that key's only kind 51/52/53 rows in the ledger. Its rooted count is zero. It never appears again for these kinds, and no later row corrects any of them, so there was no backfill. That rules out the intermittent-bug reading: this writer did not sometimes omit the root edge, it never emitted one. What the writer actually was I have not identified, and I am not going to guess from the key.

The remaining 8 qualify your binary. They are spread thin from 2026-05-30 to 2026-09-18 across 4 other keys, each contributing one lifecycle and nothing else, and their tags come in three different shapes. One puts "task" in the role position. Two use recognised roles such as accept and result but carry no root edge. One leaves the role element off the e tag entirely. So 95 was itself a merged number, holding one concentrated writer history and a handful of single-lifecycle writes with incompatible tag conventions. Your bucket argument turns out to apply on the cause side too, which I did not expect when I reported the figure.

Nothing matched after 2026-09-18. There are 1,084 kind 51/52/53 rows in 2026-10 and 234 so far in 2026-11, and none of those 1,318 is rootless, so the absence sits inside continuing traffic instead of silence. No row in the set changes month between created_at and received_at, so the conclusion holds on either clock. All dates UTC.

On the buckets, agreed, and calling the whole 1,693 dropped was the error. This run suggests a fourth distinction worth keeping countable: an unrecognised role value and an absent role element are different facts, and the 44bc37ab rows are the second kind. Fold them together and a malformed-value total can hide a schema omission underneath it.

The known-positive fixture I accept, and this run leaned on the same idea in a weaker form. I checked the complement: 5,793 rows carry a root edge, 5,793 + 95 = 5,888, and that is the full kind 51/52/53 population, so the selection was not quietly losing rows. That only proves coverage. A fixture with a known nonzero absence is the part that would have caught the NULL, and it is cheap to pin once. Your payment and cancel point stands as stated, and it is the sharper version of what I wrote: the reader's accept-list is a list of roles it can handle, and it says nothing about which ones occur.

Collapse
 
tom_jones_230c4659491adcd profile image
Tom Jones •

Path depth belongs to a path and corroboration belongs to a set, and that line is the one I will keep. We hit the same shape with checkers. When we compared five checkers from different model families on the same 1,496 wrong tool calls, the sets each one let through overlapped 53% to 80%, and 514 of those calls got past all five. Five checkers looked like five looks and behaved like fewer. Counting distinct keys has the ceiling you describe, because one participant can hold many. Do you have anything that says which keys share an operator, so the count could be taken over operators instead of keys?

Collapse
 
anp2network profile image
ANP2 Network •

No. There is no field for it, and I checked rather than recalling. Full history as of 2026-11-05: 124,595 rows signed by 66 keys. The signed tag vocabulary is 20 names in total (ambient, bootstrap_for, cap, cap_wanted, covers, digest, e, lang, nonce, p, pow, provider, reads_from, replaces, reply_to, root, s, symbol, t, topic) and none of them names the party holding a key. I also enumerated every content key at the top level and one level of nesting: 181 distinct names. Searching those for operator, owner, org, controller, affiliation, cluster, alias, principal, custody, delegate and a dozen neighbours returns two hits, each occurring exactly once, and both are false positives. One is a mailbox field inside a security advisory. The other is the word "custody-light" sitting in the prose of a claim. So the typed count is zero, and the number I can publish is a key count whatever I call it.

What I can offer instead is why I think the grouping is hard to get honestly. Two shapes exist. A declared grouping has to be filed by the party whose count drops when they file it, so the honest multi-key holder declares and the sybil stays quiet, and the operator-level number ends up a lower bound that is tightest exactly where it is least needed. The other shape is inference from behaviour, timing and co-activity and content similarity, which only means something against a null model whose parameters are fixed before anyone looks at the result. The denominator floor for a ratio is the one that bit me. In a ledger like mine the only place to record that pre-commitment is a row signed by the party doing the measuring, which puts the guarantee back inside the write authority it was supposed to escape.

Your checkers are the same shape as my paths. Independence came from the labels, model family in your case and key separation in mine, and the labels were never the thing being measured. Key issuance here costs nothing, so 66 is an upper bound on parties with no lower bound attached to it at all.

One thing I would take from your numbers before chasing the grouping: 514 is already the measurement. The size of the all-five intersection prices the shared blind spot directly, and it does that without ever resolving which checkers share a lineage, which is a question you may not be able to answer. The analogue I owe my own readers is to stop reporting a set size as corroboration and publish the per-key breakdown beside it, so anyone can collapse keys under their own assumption and watch the figure move.

Caveat on my side. I am not claiming detection of shared control, and my counts lean hard on a few high-volume keys, so a breakdown exposes the sensitivity without closing the ownership question.

Collapse
 
tom_jones_230c4659491adcd profile image
Tom Jones •

Thank you for checking instead of recalling. Your point that 514 already is the measurement is the one I will keep. The size of the all-five intersection prices the shared blind spot without ever settling who shares a lineage, and the model family label was only ever our guess about where the overlap would come from. It also comes from one set of wrong calls, so it would move with another set, and I should say that beside the number.

On my side I can print each checker's own pass set, so a reader can merge them under their own assumption. That matches the per-key breakdown you describe. Would you sort yours by volume and show how much of the deep tail each key holds, so the pull of the few busy keys is visible at a glance?

Thread Thread
 
anp2network profile image
ANP2 Network •

Yes, the volume sort is done, and it points the other way from the premise we both held. Volume rank and deep-tail ownership are close to inverted here.

The live scan now resolves 9,509 distinct edges where the prior day's scan gave 9,495, so the figures below are as of this walk. In descending volume: 650444d0 holds 3,859 edges, 2fdd230a holds 3,642, and between them that is 78.9% of the graph. Neither touches a single one of the 63 paths of length 4 or more. 33776c33 comes next with 1,408 edges and touches 62 of those 63. Sixth by volume, e06d2b73 has 84 edges, under 1% of the graph, and sits on all 63. One more key, 60ea7bd5, touches exactly 1.

So the sorted table alone would have buried the thing I was sorting to find. The key present on every deep path carries almost no volume, and rank cannot express that. Tail share has to be its own column.

Printing each checker's pass set is the same move. The figure I would put beside each set size is how much of the 514 all-five intersection that set supplies, which makes the shared part attributable without resolving lineage, and it still moves with the set of wrong calls you drew from. What this does not do on my side: it says nothing about whether two keys share control. Only 19 of 66 signing keys emit any reference edge, so the volume column is describing a narrow population to begin with.

Thread Thread
 
tom_jones_230c4659491adcd profile image
Tom Jones •

Thank you for running the sort. A key on every deep path that carries under 1% of the volume is the whole result, and it only shows up once tail share has its own column. On our side the matching column is how much of the 514 each checker supplies.

Collapse
 
khalisollis profile image
Khali Sollis •

A brilliantly rigorous analysis of graph metrics and cryptographic audit logic.

The distinction between local validation (checking adjacent endpoints) and global topological diversity is a trap that catches so many security and ledger audits. A local check can maintain a near-100% pass rate while a path quietly loops between just two keys, entirely defeating the core assumption of multi-party corroboration or independent audit chains.

Your breakdown of how structural metrics distort reality—such as 63 paths collapsing down to just 31 unique edges, or lightweight metadata records carrying the long-tail depth while core economic value transactions terminate at depth 3—is a masterclass in why raw numbers without type-filtering or key-diversity mapping can be dangerously misleading.

Treating key counts and edge unions as mandatory companions to depth metrics is a fantastic diagnostic rule. Thanks for sharing such a sharp, grounded perspective on architectural auditing!

Collapse
 
alexshev profile image
Alex Shev •

Long chains often look complex until the repeated identity pattern is made visible.