DiscoverStage 5 of 9
Why two indexers
Bitcoin holds the object. It does not tell you what the object is.
Reading a Tandem object means locating the configured INIT transaction, classifying every transaction in every block since, applying a state machine, and computing three roots. That work produces the answer to every question a person actually asks: how many chapters, whose keys, is it still active. Whoever does that work is the trust you are taking, and in most protocols that is the part nobody looks at.
The problem, stated plainly
Section titled “The problem, stated plainly”An indexer can be wrong without being dishonest. A missed transaction, an off by one in a height comparison, a subtly different tie-break in an ordering rule, and the state it reports diverges from the chain while everything about it still looks healthy. It answers quickly, its uptime is good, and it is wrong.
Asking the same implementation a second time proves nothing. Asking a second copy of the same code proves nothing either, because the same bug lives in both.
The answer
Section titled “The answer”Two implementations that share nothing. This repository’s own architecture note is blunt about it: pipeline B must use a separate codebase, node, store, owner, and release process. Separate code so a logic bug is not shared. Separate node so a chain view is not shared. Separate database so a corrupted row is not shared. Separate owner and release process so a bad deploy cannot hit both at once.
That comparison is worth doing because the specification makes it decisive. Given the same deployment binding, the same specification bytes and the same canonical blocks, independent implementations are required to produce identical events, reason codes, state, counters, event roots, object-state roots and chained roots at every height. If two of them differ, one of them is wrong. There is no third possibility to negotiate.
Each pipeline signs its own view. The agreement tuple is fourteen string fields, canonicalized with RFC 8785 and signed with Ed25519. The signature covers the canonicalized tuple only, so the envelope’s schema, key id and signature fields are not themselves authenticated.
Pipeline A and pipeline B independently reached the same canonical height and signed identical protocol state. The gateway compares the two tuples, finds no difference, and serves the data.
Pipeline Athis repository
Pipeline Bseparate team, separate code
What the caller receives
What the operator sees in the log
The caller is never told which check failed. Every failure returns the same body, so a probing client cannot map the gateway's internals.
Modelled from src/verification/verified-gateway.service.ts. The nine compared fields, the status codes, and the response bodies are the ones in that file.
The nine fields that must match
Section titled “The nine fields that must match”Compared in this declared order, by exact string comparison. The first difference throws
agreement mismatch at <field> and nothing is served.
| Field | What a match establishes |
|---|---|
protocol_id |
Both read the same deployment: same network, same configured INIT txid |
height |
Both answered for the same canonical height |
block_hash |
Both are on the same chain at that height, not merely at the same number |
event_root |
Both classified the same events, in the same order, with the same reason codes |
object_state_root |
Both hold the same object snapshots: status, sequence, keys, chapter counts |
chained_root |
Both agree on every block from the INIT confirmation block up to this one |
founding_created |
Same count of founding objects |
all_objects |
Same count of objects |
active_objects |
Same count of objects still active |
The block hash line is the one to sit with. Two indexers can agree on every count and every root and still be looking at different blocks, which is precisely the case a naive comparison misses.
The four fields that are deliberately not compared
Section titled “The four fields that are deliberately not compared”Each tuple also carries a parser commit, an indexer commit, a parser binary hash and an indexer binary hash. Those four are never compared.
Requiring them to match would require the two pipelines to be the same program, which would destroy the only property that makes the comparison worth anything. Two independent implementations have different source commits and different binaries by definition. So the gateway keeps each pipeline’s signed release identity separately in the response instead of comparing them. A caller can see exactly which two builds agreed, which is more useful than a match that could only mean the pipelines were never independent.
The schema field is not compared either, because both tuples are rejected outright unless it reads
exactly urn:tandem:agreement-tuple.
Key ids and signatures differ per pipeline in any real deployment, and neither is compared. Note what that leaves to the operator: nothing in the code checks that the two trusted key maps are disjoint. The same key id and public key configured on both sides would satisfy both halves of the comparison with one signer, and the independence the rest of this page describes would be gone without any error appearing. Keeping the two maps separate is a deployment responsibility, not a checked one.
One failure body for everything
Section titled “One failure body for everything”Every distinct failure returns HTTP 503 with the same body:
{ "status": "verification_unavailable", "error": "verification_unavailable" }Pipeline A not ready. No canonical height. Mainnet not deliberately enabled. Pipeline B endpoint not configured. Network error, timeout, oversize response, a key id absent from the trust map, an invalid signature, a wrong shape, a semantic mismatch, or the agreement changing between the two resolutions that bracket the read. All of them produce that body.
A caller cannot probe which check failed, which means a caller cannot map the gateway’s internals or
work out which of the two pipelines is having a bad day. The reason is written once to the operator’s
log as verified response withheld: <message> and stays there.
Verification happens twice per request
Section titled “Verification happens twice per request”One verified request resolves the agreement, runs the query, and then resolves the agreement again. If the two resolutions differ in any way, the response is thrown away even though it was correct when it was read. That costs two readiness probes, two signings and two requests to pipeline B per call. Nothing is cached or reused.
What is not switched on
Section titled “What is not switched on”Nothing here is a quorum, a threshold, or an N of M vote. Exactly two envelopes are compared, both have to be trusted, and either one of them being absent closes the surface.
Break it yourself with the model above, then read the questions people ask for the shorter answers.