Cellora — designing a production indexing and query service for CKB (feedback welcome)

Hello Nervos community,

I’m building **Cellora**, a multi-tenant indexing and query layer for CKB, and I’d value input from this community before the API surface and reorg handling land. The project is in active development — ingestion is shipped, the query surface is next — and I’d rather bake your feedback into the design than retrofit it later.

Repo: GitHub - Antismart/cellora · GitHub

## The problem

Anyone shipping a serious CKB application runs into the same fork in the road: query patterns that a full node alone can’t answer efficiently. Live cells by lock, cells by type, transaction history for an address, outpoint resolution, balance aggregation — these need a normalized store with indexes tuned for those access paths.

The options today are:

- **Run the built-in `ckb-indexer`.** Excellent for local, single-user workloads. Not designed as a multi-tenant data layer.

- **Run Mercury.** More ambitious in scope, but operating it in production is still every team’s problem.

- **Roll your own indexer.** Every app ends up reimplementing a variant of the same indexer, database schema and ops story.

- **Lean on public RPC.** Rate-limited, no query-layer features, no SLA.

The result is that every CKB team pays the “indexer tax” independently. Cellora’s bet is that a managed, multi-tenant indexing service with strong operational properties (reorg safety, observability, SLOs) can be the shared infrastructure that lets DApp teams focus on their product.

## What Cellora is

A managed data layer for CKB. It normalizes blocks, transactions and cells into PostgreSQL with indexes tuned for the common access patterns (by lock hash, type hash, outpoint), fronts them with REST and GraphQL behind API-key auth and per-key rate limiting, and treats reorgs as a first-class case rather than an edge case.

## The shape

Three planes that scale independently:

**Ingestion plane.** A single-writer indexer tails the chain, parses blocks into normalized records, and writes them transactionally to PostgreSQL. It is the only component with write access. It owns reorg handling: on a parent-hash mismatch it walks back to the common ancestor, rolls back the affected blocks, cells and transactions in one DB transaction, and resumes forward.

**Query plane.** Stateless Rust services behind Axum exposing REST and GraphQL. They read from PostgreSQL replicas and a Redis cache. Every response carries the indexer’s tip height so clients can compute their own freshness.

**Edge and control plane.** TLS termination at the edge, Argon2-hashed API keys, and a per-key Redis token bucket for rate limiting with separate buckets for REST and GraphQL.

The load-bearing decision is separating ingestion from query. It keeps the write path serial and easy to reason about while letting the read path scale horizontally on request volume.

Full architecture, schema and reorg algorithm: cellora/docs/architecture-overview.md at main · Antismart/cellora · GitHub

## Why CKB shapes the design

A few places where CKB’s primitives drove specific choices:

**1. Cells as first-class rows, not events.** CKB’s cell model maps naturally onto a row-per-cell table. Live/dead state isn’t derived — it’s a column (`consumed_by_tx_hash IS NULL`). A single indexed query on `lock_hash` filtered by that predicate returns the live set for a lock. No event log replay, no materialized view rebuild.

**2. Script components stored raw *and* as precomputed hash.** A CKB script has three parts: `code_hash`, `hash_type`, `args`. Each cell stores all three raw alongside the precomputed `lock_hash` / `type_hash`. Exact-hash lookups are O(1); pattern matching on `args` prefixes (for xUDT owner filtering and similar) is possible without a script-specific schema.

**3. Reorgs as a first-class case.** The write path is built around transactional rollback from the start. On a parent-hash mismatch, the indexer walks back to the common ancestor and deletes blocks `(A, tip]` in a single DB transaction — `ON DELETE CASCADE` removes associated transactions and cells, and cells consumed in rolled-back blocks have their `consumed_*` columns reset to `NULL`. `indexer_state` advances to the ancestor in the same transaction.

**4. Single-writer ingestion matches chain-tip semantics.** Blocks arrive in canonical order; one tip means one writer. Single-writer eliminates cross-process locking on the cells table and makes the reorg algorithm trivially correct — there’s no concurrent writer that might observe a half-rolled-back state.

## Current status

Block, transaction and cell ingestion is shipped. Live/dead cell accounting, one-transaction-per-block write semantics, and indexer-state tracking for tip recovery are working end-to-end against a dev chain.

What’s working:

- Block polling loop (`get_tip_block_number`, `get_block_by_number`, `get_blockchain_info`) at a configurable 2 s cadence

- Parser and writer that normalize blocks → transactions → cells

- Live/dead cell accounting via `consumed_by_tx_hash`, `consumed_by_input_index`, `consumed_at_block_number`

- One PostgreSQL transaction per block (indexer state advances inside the same transaction)

- Partial indexes on `type_hash` (nullable) and `consumed_by_tx_hash` (live cells stay out)

- Graceful shutdown on `SIGINT` / `SIGTERM`

- Integration test harness that spins the full stack via docker-compose

What’s not yet: REST and GraphQL surfaces, API-key auth, Redis-backed rate limiting, reorg handling, webhooks, partitioning, and the observability stack.

## Architecture

```

cellora/

├── Cargo.toml # workspace root

├── crates/

│ ├── common/ # config, CKB RPC client, shared types

│ ├── db/ # SQLx models, queries, migrations glue

│ └── indexer/ # poller, parser, writer, main

├── migrations/ # sqlx-cli managed SQL migrations

├── docker-compose.yml # Postgres + Redis + CKB dev node

├── docs/

│ ├── architecture.md

│ ├── architecture-overview.md

│ └── decisions/ # ADRs

└── README.md

```

## Tech stack

| Layer | Technology |

|—|—|

| Language | Rust (stable) |

| CKB RPC client | `ckb-jsonrpc-types` + `reqwest` |

| Database | PostgreSQL + SQLx (compile-time query checking) |

| HTTP (planned) | Axum |

| GraphQL (planned) | async-graphql |

| Cache and rate limiting (planned) | Redis |

| Observability (planned) | OpenTelemetry + Prometheus |

| Deployment | Docker Compose (local), Kubernetes (production) |

## Key design decisions

A few non-obvious choices worth calling out, because this is where I most want pushback:

**Polling over subscriptions.** The indexer polls on a 2 s cadence rather than consuming the JSON-RPC subscription APIs. Polling recovers from transient connection loss without special cases and keeps the failure modes tiny — the cost is a few seconds of indexing lag, which is below the noise floor for a data layer. If CKB node subscriptions are stable enough in practice to be worth it, I’ll revisit.

**One transaction per block, always.** The block row, its transactions, its output cells, the updates marking consumed inputs, and the advancement of `indexer_state` all commit together. Readers never see a partial block, and the recorded tip can never be ahead of the data. The tradeoff is that a very large block is one large transaction — acceptable for CKB’s block sizes but something to watch.

**Database is a cache, not a ledger.** Every record in PostgreSQL is reconstructable from the node. Recovery from any corruption is “reindex from a known good height.” This keeps schema evolution cheap — no migration needs to preserve data, because it can always be rebuilt. The tradeoff is initial backfill time on mainnet, which we’ll mitigate with snapshots.

**Script representation stores components and hash.** Each cell row carries `lock_code_hash`, `lock_hash_type`, `lock_args`, `lock_hash` (and the same four for the type script). Exact lookups hit the hash; pattern matching works on the raw components. No script-specific schema needed in the base layer, and well-known script tagging can be layered on top as enrichment.

## What’s next

The critical next milestone is the **REST and GraphQL query surface plus reorg handling**. Ingestion without a query surface is a tree falling in the forest, and ingestion without reorg safety isn’t production-ready against mainnet. These two ship together because reorg events need somewhere to be published for the query layer to invalidate caches.

After that, in order:

- API-key auth (Argon2) + Redis-backed per-key rate limiting, separate buckets for REST and GraphQL

- Webhook delivery with HMAC signatures and exponential-backoff retries

- Range partitioning on the `cells` table by block number

- OpenTelemetry tracing + Prometheus metrics + Grafana dashboards

- Dashboard for API-key management, usage charts, and a query explorer

- GraphQL subscriptions over WebSocket for live cell updates

## Where I’d value input

The questions where I most want community input, design-leaning first, then product-leaning:

1. **Cell model fidelity.** Is storing script components raw + precomputed hash enough, or are there query patterns (partial `args` matching, script-class tagging) that should be first-class in the schema?

2. **Reorg depth in practice.** What rollback window should I size for on mainnet? Anecdotes of observed depths, even rough ones, would help.

3. **Polling vs subscriptions.** Are there stability or compatibility concerns with the CKB subscription endpoints the community has hit?

4. **Well-known script registry.** Tagging common scripts (Sighash, MultiSig, Omnilock, xUDT, Spore, RGB++, Nostr binding) is out of scope for the base schema. Is there a canonical ecosystem registry, or is everyone maintaining their own?

5. **Product fit.** For anyone shipping a CKB app today: how are you accessing chain data (own node, built-in indexer, Mercury, public RPC, third-party), and what breaks most often? Which query patterns and which interface (REST, GraphQL, typed SDK, subscriptions) do you reach for first?

If you’d rather leave structured feedback, there’s also a short survey: Help shape CKB's data layer

Happy to jump on a short call with anyone whose app has non-trivial indexing needs — concrete pain points are what I most want to hear. Direct and critical feedback is welcome; I want to get this right before mainnet.

Thanks for reading.

5 Likes

hi @Antismart , welcome to the community! I see Neon is surveying about indexing requirements as well. I added this comment on Github but I noticed the post here and figured this is a better place for conversation.

The question about RPC’s with CKB is a long-standing one, and grateful to see you stepping into the fold to find how to make CKB more accessible to developers (and ones that would like to avoid running infrastructure themselves)

Here’s the comment from Github, I’m sure there will be plenty of ideas around RPC shared on this post, appreciate your interest.

——————————————–

Really appreciate the work you are doing on this, it does seem like an important piece for many people who are coming into the ecosystem, and the current public RPC’s were never intended to be used for anything more than testing.

I want to highlight an important thing about RPCs:

given the recent $300m hack in the Ethereum ecosystem originating from compromised RPCs, integrity of data from the RPC could be considered as part of the feature set as well.

Any cell data can be proven through previous transaction inclusion, which can be proven through Merkle branches up to a block header, which can be proven up through Merkle branches to the Block MMR field in the extension block of a recent block header.

Through Flyclient, the client can verify that this is all from a recent block in the chain tip.

I know this may seem overly burdensome at this stage, however if these things were considered a decade ago in the Ethereum community and others, the $300m hack would have never happened. It seems like more of an architectural consideration across the ecosystem right now, rather than things that should be required for implementations, this is why I mention it.

There is a downstream consideration of the client verifying what it is signing as well.

I can see this property where “like likes like” , in that insecure paradigms will lead to more insecurity, whereas secure paradigms will lead to more security, simply because it feels strange (or at least worthy of inquiry) to create a insecure thing downstream from a secure thing, while creating a further insecure thing downstream from an insecure thing seems perfectly pragmatic.

8 Likes

Thanks, really grateful for this. “Like likes like” is the clearest framing I’ve seen for this class of risk, and the $300m example makes it concrete.

Right now Cellora is a trusted oracle. Clients ask, we answer, they sign. Same posture every production CKB indexer sits in, but you’re right that naming it is the first step. I’ll add a trust-model section to the architecture so it’s explicit.

CKB makes the verifiable path more tractable than most chains. get_transaction_proof already returns tx-to-header Merkle branches; passing those through gets us from “trust Cellora” to “trust Cellora gave you the right header.” The MMR commitment in the extension field closes the loop — with a recent trusted header, Flyclient lets a client verify history without trusting us at all.

Three steps in order of ambition:

1. Annotate every cell and tx response with block_hash and block_number, not just the envelope tip. Small change, immediate cross-check against a client’s own node.

2. Expose tx inclusion proofs and headers as opt-in responses. Clients verify tx-to-header themselves; we’re reduced to a faster cache.

3. Full MMR / Flyclient bundles. Verification doesn’t rely on us at all.

A few things I’d rather ask than guess:

1. Is there a canonical Rust or TS reference for verifying CKB MMR proofs on the client side that I can point client-library authors at?

2. On Flyclient parameterisation — is the community converging on specific sample counts / variance, or is it still open?

3. Your "downstream consideration of what the client is signing" — is that a CCC / wallet concern, or pointing at something broader? A separate conversation I’d like to have.

Thanks again, this is really helpful.

4 Likes

Just came across this thread and found it interesting, so I’ll try to offer a few suggestions. I think the honest answer is:

For tx inclusion proofs, the practical first step is likely not Flyclient, but exposing CKB’s existing get_transaction_proof / verify_transaction_proof path through Cellora. That lets clients verify that a transaction is committed under a particular block header, rather than merely trusting Cellora’s indexed result. This easily moves Cellora from a purely trusted indexer toward an inclusion-verifiable indexer.

For full historical / chain-tip trust minimisation, my narrower point is that I’m not sure there is a canonical Rust/TS wallet-side verifier package that app developers can just plug into today. So for Cellora v1, I’d probably keep MMR/Flyclient-style support as a later integration layer, not a hard requirement.

3 Likes

Without the Flyclient proof, I have trouble seeing how the client can verify that the block header returned from Cellora has been included in the chain. Am I missing something?

1 Like

Yes, agreed. I was following Antismart’s staged framing here.

I meant that get_transaction_proof / verify_transaction_proof is useful for the step-2 property: tx-to-header verification. It does not by itself prove that the returned header is canonical.

The full trust minimisation later on still needs Flyclient, a local node, or another trusted header source.

3 Likes

Even if the returned header is anchored to consensus, there is still the harder question: how does the client prove that no matching cells or txs were omitted from the answer?

If the client wants both consensus anchoring and query completeness for itself, it still needs its own light client or full node.

The more interesting design space is not “one provider proves everything”, but “independent parties reproducing the same canonical view at the same tip”.

Been chiseling away at a design in that direction: not fully trustless, but potentially a useful middle ground for users willing to trade a bit of trust for much less sync time.

Phroi

3 Likes

@phroi you pushed on exactly the right gap, so let me be straight about what it does and doesn’t solve, and the direction we’re taking on it.

The concession first, because it’s real: verifying a result the API returns (inclusion proof to a header, block and tip annotations, which we already do) is a different guarantee from proving nothing matching was left out. Full non-omission, in the general case, needs the client to derive the set from its own light or full node. I’m not going to pretend otherwise.

But there’s a middle ground that’s checkable rather than hand-wavy, and it’s the direction we’re building toward. Two primitives, both leaning on the fact that CKB’s indexed schema is fixed and the cell model is deterministic:

  1. A state root / proof-of-indexing: the indexer commits a deterministic, content-addressed root of the indexed set at each tip. Because it’s a function of consensus-anchored blocks over a fixed schema, any independent operator indexing the same chain to the same tip should reproduce the identical root. CKB gives this almost for free, since there’s no arbitrary mapping logic to diverge, which is the thing The Graph had to invent PoI to paper over.
  2. Coverage proofs: if the query indexes are an authenticated structure, a filtered query (“live cells with lock_hash X at tip T”) can return a range proof that the result is exactly the set under that key in the committed root. That converts “trust that this is all of them” into “here is a proof this is all of them, relative to root R.”

Put together: coverage proofs give complete-with-respect-to-R, and independent operators reproducing R at tip T (plus anchoring R to consensus via the MMR/Flyclient work @matt_ckb raised earlier) give R-is-the-canonical-view. That’s close to your “independent parties reproducing the same canonical view at the same tip,” which I think is the correct frame.

One thing I want to be explicit about so nobody reads more into this than is there: this is trust-minimisation for a hosted service, not a pivot to a staked, tokenised network. We looked hard at the full-protocol shape and deliberately decided against it. The economics need query volume and token liquidity that don’t exist yet, and the overhead would swamp the actual product. Reproducibility and an optional plain CKB bond, slashed only on proven divergence, get most of the trust benefit without a token.

And the residual, stated plainly because you’d catch it anyway: if every operator runs the same derivation rule with the same bug, they agree on a wrong R, and only a client re-deriving from its own node closes that. So checkable and reproducible, not trustless. Roughly the “trade a bit of trust for much less sync time” middle you described. To be clear on stage, this is the direction, not shipped code yet, we’re a private alpha mid-build.

You mentioned working in this direction. Before anything gets hardened the useful thing to align on is the commitment format for R: what gets hashed, at what tip granularity, and which authenticated structure keeps coverage proofs cheap for the real query shapes. If independent indexers are going to check each other, that’s the interface that has to match. What are you leaning toward?

3 Likes

Hey @Antismart, thanks for coming back to this!!

We agree on the boundary: inclusion proofs can show that returned data is real, but not that the answer is complete. Your question about R connects directly to something I explored for CKB: light-client snapshots.

My snapshot idea

A CKB light client does two different jobs during its first sync:

  1. Verify headers with sampled headers and MMR proofs. This stays relatively cheap as the chain grows.
  2. Find the history of its watched scripts. This requires testing filters from each script’s start block to the tip, fetching matching blocks, and building local cell and transaction indexes. This scan grows with the chain and costs much more.

A snapshot skips that historical scan. It is a portable export of indexed state at a pinned tip. A new client imports state for the scripts it needs, checks the tip, then resumes normal light-client sync.

The release is not one database image. It contains shared chain state plus one payload per exact lock or type script. When that subset is small enough, a wallet or DApp can run one local client per site or temporary task instead of relying on a broad shared indexer or downloading unrelated history.

Payloads are canonical sorted key/value streams, independent of RocksDB, SQLite, or IndexedDB. The manifest binds them to genesis, the exact tip number and hash, implementation revisions, decoded sizes, and SHA-256 digests. Compression does not change payload identity.

A consumer reads the manifest, selects its scripts, downloads the shared and selected payloads, checks the network, tip, sizes, and digests, imports the rows into its own light client, verifies the tip against peers, and syncs new blocks normally.

The publication contract includes a schema, manifest tools, a download planner, and a Rust exporter that incrementally derives per-script payloads from contiguous full-node-derived blocks. It does not yet include an importer, benchmarks, multi-provider quorum, or coverage proofs.

Why reproducibility matters

A snapshot is a shortcut, not a proof of complete history. A full node remains the strongest independent path. The practical lightweight path is syncing a light client from genesis under its peer assumptions. If we skip that replay, we trust the snapshot provider for completeness before the snapshot tip.

Even so, I would often prefer a snapshot-bootstrapped local client to querying an unowned remote node. A snapshot is pinned and content-addressed, so it can be cached, mirrored, compared, rebuilt, and challenged. After import, the user owns the client and follows new state through the light-client protocols. A remote server can tailor every answer over time.

This is an operational advantage, not a cryptographic proof. An incomplete snapshot stays incomplete until someone replays the history. The gain is a stable artifact that independent parties can reproduce and compare instead of a stream of opaque answers.

The base format works with one provider. Confidence improves when independent exporters publish at the same tip and compare digests. Cold rebuilds, challenges, signatures, and transparency logs can expose unilateral tampering, drift, and equivocation, but not shared bugs, collusion, or common omissions. The model document covers these limits.

What the snapshot work answers about R

This is where I got to:

  1. What gets hashed: canonical logical rows, including the full script and whether it is a lock or type view.
  2. Tip granularity: both tip_block_number and tip_block_hash, since height alone does not identify a fork. Publication cadence can remain an operational choice.
  3. Authenticated structure: I explored proof-based variants, but chose none for the snapshot format.

Proof-carrying snapshots can verify included headers, raw transactions, and derived rows. They cannot show that matching history was not omitted. They also add bytes and exporter work. I did not think that trade was worth it.

A compact completeness proof instead needs an authenticated global commitment. CKB does not currently expose one for filtered light-client state and history. Building one would be a separate infrastructure or ZK project requiring its own funding and owner.

The snapshot design therefore uses flat payload digests and independent reconstruction. This makes complete artifacts comparable, but gives no cheap coverage proof for one query. The rejected alternatives explain the choice.

I do not have the time or interest to lead the proof project or push the snapshot work forward. If any part is useful for Cellora, please reuse or rework it. I would be happy to see you take it forward in whatever shape fits Cellora.

Keep it up, Phroi

Hey @phroi, I went through the light-client-snapshots repo, the spec and the model doc, before replying, since your first answer clearly wasn’t a throwaway.

The thing that reframed it for me is that we’re solving two different shapes of the same problem. Your snapshot is per-script and it hands the client back ownership: import the payloads for the scripts you watch, check the tip against peers, then resume RFC 44/45 yourself. Cellora today is the opposite, a hosted indexer answering arbitrary filtered queries where the client never runs anything and every answer is opaque. So when I reached for coverage proofs I was trying to make the opaque answer self-justifying, and your model doc is right that this is a dead end. The rejected-alternatives section says it plainly: a provider can omit a matching historical transaction and still return valid proofs for every included one. Proofs settle inclusion, not non-omission, and the only thing that settles non-omission is replaying the scan. I was treating “complete relative to R” as completeness, and it isn’t.

What I hadn’t clocked until the model doc is that this isn’t a “not built yet” gap, it’s structural. CKB doesn’t expose a compact commitment for filtered light-client state or for historical script history, and a live-cell-by-outpoint commitment can’t answer “every cell matching script X” without walking the whole tree. A prefix tree could give range proofs for live cells, but you’d need separate lock and type views, and history needs its own commitment on top. That’s a protocol or ZK project with its own owner and funding, not something either of us bolts onto an exporter. Worth stating out loud so nobody wanders back in expecting a shortcut.

So I’m on the reconstruction path, and the more interesting question for Cellora is whether it should just be an exporter in your format instead of inventing a parallel one. Cellora already holds the indexed state, so publishing canonical .kvpairs payloads at a pinned tip, shared state plus per-script lock and type payloads, with the manifest binding genesis, tip number and hash, and the SHA-256 digests, is a natural feature. The payoff is exactly the ownership flip you describe: a wallet or DApp bootstraps its own local client from a Cellora snapshot and then follows the chain itself, instead of trusting our query endpoint forever. And if we publish in your format, your cold-rebuild auditors and anyone else’s exporter can reconstruct at the same tip and compare digests against ours. That interop is worth more than a format I’d own alone.

I want to keep the trust story as honest as your doc keeps it. Multi-exporter agreement on a digest is operational evidence, not proof: it catches tampering, drift, and equivocation, but not shared bugs, collusion, or a common deterministic omission. The only real floor is a full node, or a light client syncing from genesis under its peer assumptions. So Cellora’s query API stays what it is, a trusted oracle with verifiable positive results, and the snapshot path sits next to it as the own-your-client option. Two honest choices rather than one dressed-up one.

Two things I’m still chewing on if you have a spare moment. First, the format is per-exact-script by design, which is what keeps payloads small and the keys disjoint and comparable. A hosted indexer is tempted toward broader exports, but I think that breaks both properties, so I’d rather stay per-script and let the client pull the set it needs. Does that match how you pictured a provider using it. Second, the reorg boundary, since the model leaves cadence and confirmation open: I lean toward provisional-then-confirmed releases after some depth with digest agreement, but I haven’t worked out how a consumer that already imported a provisional snapshot reconciles if the tip it pinned gets orphaned.

Either way, thank you for putting this in the open and for the offer. I’ll credit the design and the thread wherever it lands in Cellora’s plan, and I’d rather build toward one shared format than two.

Yes. Keep one exact script plus its lock/type kind as the canonical unit, with shared state once per release. Bundles can contain unchanged per-script payloads and digests. Bundling keeps the keys separate; one aggregate payload would lose selective downloads and per-script comparison.

I would not publish provisional snapshots to consumers. At our four-to-six-hour cadence, choose a tip already buried by a fixed depth, compare the candidate privately, then publish it once. Depth, not cadence, is the safety rule. A consumer should still check that the pinned tip is on the accepted chain. Matching digests prove that exporters produced the same artifact, not that the chain is stable.

Given CKB’s mining-pool concentration, I would call the tip operationally stable, not final. No depth protects against every majority-miner reorg, and snapshots cannot solve that consensus risk. If a deep reorg removes the tip, invalidate the release and import a replacement instead of merging over the orphaned state.

My private life leaves me little focused time these days: I probably cannot help with the remaining details, so please use your own judgment and move forward without waiting for me.

I am glad you are working on something that matters, and I hope this becomes useful for Cellora.

Phroi

1 Like

This is a clean place to land it, thank you. Both answers fit, and a couple of them corrected instincts I’d have gotten wrong.

Depth over cadence is the one I’d have fumbled. I was treating the publish schedule as the safety knob when the buried depth is the thing that matters, and “compare privately, publish once” is obviously right once you say it. Invalidate and replace on a deep reorg rather than merging over orphaned state matches how Cellora already handles reorgs internally, so that part slots in without a fight. And I’ll keep the framing you used, operationally stable rather than final, especially given the miner concentration. That belongs in the docs, not buried in a footnote.

Per exact script plus lock/type kind as the unit, shared state once, bundling allowed but never aggregated, is settled for me. It also draws the line I wanted between the two shapes: the snapshot path is for someone who watches specific scripts and wants to own their client, and the query API stays the thing for ad hoc questions where the caller accepts the trusted-oracle posture.

The one piece I know is mine to get right, and I’d rather say it out loud than pretend it’s free: Cellora indexes into Postgres, not the light client’s key layout. For our export to be comparable, the decoded bytes have to match what a from-genesis light-client sync would produce for that script, not just what Cellora happens to store. If they don’t, the digest is just another single-provider artifact and the reproducibility point is gone. So the real work is re-deriving the canonical LC rows from our indexes, byte for byte, and testing them against a cold rebuild. That’s the bar, and it’s on me, not something to hand back to you.

I’ll take it forward from here and credit the design and this thread wherever it lands. Genuinely appreciate you putting the work in the open, and taking the time when you have little to spare.

2 Likes