> For the complete documentation index, see [llms.txt](https://cantonindexer.infradao.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://cantonindexer.infradao.com/milestones/m2.md).

# M2

Canton Indexer is the product the grant proposal calls Canton Index. This page lists what the proposal planned for Milestone 2, what was delivered, and what changed along the way.

M2 is delivered as [v0.10.0](https://github.com/InfraDAO/canton-indexer-releases/releases/tag/v0.10.0), released on 2026-10-22. It is the first release of the indexer node `canton-indexer`, which keeps each deployed filter's documents up to date in its own Postgres store. The node is the public container image `ghcr.io/infradao/canton-indexer-releases/canton-indexer:v0.10.0`, for `linux/amd64` and `linux/arm64`, and each release carries `canton-indexer-config.md`, which lists every node setting. `cinder` 0.10.0 adds `deploy`, `ls` and `info`, which deploy or replace a filter on a node, list a node's filters and show one in detail. The release notes of v0.6.0 to v0.9.0 track the M2 work as it progressed.

v0.10.0 was cut from the source as it stood, so it also carries work done for Milestone 3: queryable select columns and their indexes, a schema per filter version, and document counts in the node's List and Get. Milestone 3 will be reported on its own page once its deliverables are complete. The source code is private; it will be published under the Apache 2.0 license by Milestone 4.

## Deliverables

| Planned                                                                                                              | Delivered                                                                                                                                                                                                                                                                                                                         | Where                                                                                                                                                                                                                                                                           |
| -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PQS reader on the SQL functions `creates`, `exercises`, `archives`, `active`, with a per-filter ledger-offset cursor | `lifecycle` filters read `creates()` and `archives()`, `exercises` filters read `exercises()`, up to `latest_offset()`. One cursor per filter, at a record's exact position `(offset, node_id)`. See [Reader and cursor](#reader-and-cursor).                                                                                     | [Filter format: Records](/filter-format.md#records)                                                                                                                                                                                                                             |
| Pipeline that loads compiled filters at startup, evaluates them against PQS rows and produces documents              | The node starts a task for every active filter when it starts. Each task reads PQS, applies `where` and `select`, and writes documents. A filter can also be deployed to a running node with `cinder deploy`. `cinder run` uses the same pipeline.                                                                                | [Tutorial: run the server](/server.md), [Install: Run the node](/install.md#run-the-node), release notes [v0.8.0](https://github.com/InfraDAO/canton-indexer-releases/releases/tag/v0.8.0), [v0.10.0](https://github.com/InfraDAO/canton-indexer-releases/releases/tag/v0.10.0) |
| Batched writer to PostgreSQL (JSONB), cursor advanced on commit                                                      | One transaction per batch writes the documents (`jsonb`) and moves the filter's cursor. A `lifecycle` filter keeps one row per contract, an `exercises` filter one row per exercise.                                                                                                                                              | [Tutorial: Read the documents](/tutorial.md#6-read-the-documents), release notes [v0.7.0](https://github.com/InfraDAO/canton-indexer-releases/releases/tag/v0.7.0)                                                                                                              |
| Checkpoint and resume without reprocessing or duplication                                                            | After a restart a filter continues strictly after its committed cursor. A batch at or behind the cursor is refused. A failed commit writes neither documents nor cursor.                                                                                                                                                          | release notes [v0.7.0](https://github.com/InfraDAO/canton-indexer-releases/releases/tag/v0.7.0)                                                                                                                                                                                 |
| Error recovery and backoff for transient PQS unavailability                                                          | A lost connection, a pool timeout, or a database error with a transient Postgres error code (such as a statement timeout) is transient; every other error is permanent. A transient error restarts the filter's task from its cursor after a wait that doubles from 1 s up to 2 min. A permanent error marks the filter `failed`. | [Install: Run the node](/install.md#run-the-node) (`RESPAWN_BACKOFF_*`), [Troubleshooting](/troubleshooting.md#a-deployed-filter-stopped-status-failed), release notes [v0.7.0](https://github.com/InfraDAO/canton-indexer-releases/releases/tag/v0.7.0)                        |
| Privacy and missing-field handling (null defaults, drop-on-missing predicates)                                       | A missing field reads as `null` in `where`; `select` states its defaults. See [Missing fields](#missing-fields).                                                                                                                                                                                                                  | [Filter format](/filter-format.md)                                                                                                                                                                                                                                              |
| Integration tests against a local cn-quickstart deployment running PQS                                               | End-to-end tests against a Splice LocalNet validator with PQS, run daily; automated tests on a PQS-shaped Postgres; manual runs on LocalNet and on our DevNet validator's PQS. See [Integration testing](#integration-testing).                                                                                                   |                                                                                                                                                                                                                                                                                 |
| Feedback checkpoint: revise the filter format after real-event testing                                               | Delivered: one revision from real-event testing (v0.3.0), and a design review with Digital Asset engineering. See [Feedback checkpoint](#feedback-checkpoint).                                                                                                                                                                    |                                                                                                                                                                                                                                                                                 |

## Changes from the plan

Plans change as the work meets real data and feedback. Each change below is recorded for clarity: the plan, what was delivered, and why.

### Reader and cursor

**Plan.** A reader on PQS's `creates`, `exercises`, `archives` and `active` functions, tracking a cursor by ledger offset.

**Delivered.** `lifecycle` records come from `creates()` and `archives()`, `exercises` records from `exercises()`. `active()` is not used. The cursor is the position of the last committed record: the offset of its transaction and the node inside that transaction, `(offset, node_id)`. A restart reads strictly after it.

**Why.** PQS keeps the payload on an archived contract's own row, so `archives()` returns each consumed contract with its payload, and `creates()` plus `archives()` give a filter both ends of a contract's life. `active()` returns only contracts not yet archived, and leaves out contracts that were never active for the validator's parties. The cursor holds the node as well as the offset so that a resume continues from the next event.

One transaction can hold several records at one position (for example a contract and its interface views). A `where` must keep at most one record per position; [Filter format](/filter-format.md#the-where-grammar) states this.

### Missing fields

**Plan.** Null defaults and drop-on-missing predicates.

**Delivered.** In `where`, a key that a record does not have reads as `null`, so `.argument.memberId != null` drops the records that lack it (as in the `traffic-purchases` example). In `select` there is no implicit default: `decimal`, `int`, `time` and `party` raise on `null`, a filter names its default with `one_of(...)` (for example `decimal(one_of(.a, .b, 0))`), and `on_error` (`halt` or `skip`) decides what happens to a record that raises. For privacy, PQS holds only what the validator's parties can see, and `.divulged_only` marks contracts that were never active for them.

**Why.** The coercions were made exact and strict (v0.3.0) after a run on DevNet data showed `Decimal` values losing digits through floating point. Strict means a coercion raises on any value it cannot convert, `null` included, so an absent field is an error until the filter names a default.

### Integration testing

**Plan.** Integration tests against a local cn-quickstart deployment running PQS.

**Delivered.** Three layers:

* **End-to-end tests against LocalNet.** A job starts Splice LocalNet 0.7.4 with PQS 3.5.8 and runs 21 tests against it. 16 cover what v0.10.0 ships. 9 run the node: a deployed filter holds the documents PQS holds, queryable columns get their indexes, List and Get report each filter, a replace backfills the new version and drops the old, a killed node resumes, a PQS outage leaves the filter active and it catches up, `on_error = "halt"` marks the filter failed, `SIGTERM` stops the node and keeps statuses and cursors, and the metrics endpoint serves the documented metrics. 7 check the PQS reader and the example filters against wallet activity seeded on LocalNet. The other 5 cover pausing, resuming and deleting a filter, which come after v0.10.0. The job runs daily and on demand.
* **Tests on a PQS-shaped Postgres.** They run the reader, the writer and the `where` SQL against Postgres 17 in a container, with the SQL functions and range checks of PQS 3.5.8 and row types captured from our DevNet PQS. They run on every change.
* **By hand.** Before the v0.10.0 release we ran the node and `cinder` against LocalNet through boot, deploy, list and inspect, indexes, replace, kill and resume, a PQS outage, halt, `SIGTERM` and metrics. Examples and the tutorial were also run read-only against our DevNet validator's PQS (3.5.8).

The LocalNet is Splice's own compose setup, not cn-quickstart.

**Why.** The tests on a PQS-shaped Postgres use rows copied from our DevNet PQS as fixtures, so they run on every change without a live PQS. A LocalNet with PQS takes about four minutes to start and about 6 GB of memory, too slow for every change, so the end-to-end tests run as a separate daily job.

### Feedback checkpoint

**Plan.** The filter format may be revised based on real-event testing before committing to the storage and query layers.

**Delivered.** Real-event testing revised the format once: [v0.3.0](https://github.com/InfraDAO/canton-indexer-releases/releases/tag/v0.3.0) made coercions exact and strict after the DevNet run above. [v0.8.0](https://github.com/InfraDAO/canton-indexer-releases/releases/tag/v0.8.0) then enforced the published list of jq functions, which closed a gap where a filter could read the host environment, and made `party` raise on a value that is not a string. Digital Asset engineering reviewed the filter format and the documentation, in a call on 2026-10-22 and then in writing; we addressed the feedback in the documentation on 2026-11-03.

### Documentation

**Plan.** The proposal places the user-facing documentation site in Milestone 4 ([proposal, Milestone 4](https://github.com/canton-foundation/canton-dev-fund/blob/2a822687b422aea1f168e92cce545e692f8b0ac1/proposals/canton-index.md?plain=1#L258)).

**Delivered.** This site, delivered with Milestone 2 as a work in progress: install, the tutorials for `cinder` and for the server, Kubernetes suggestions, filter format, troubleshooting, and these milestone pages. The Milestone 4 deliverable, a documentation site with a getting started guide and the query API reference, still lands in Milestone 4.

**Why.** In the Milestone 1 review, Jatin recommended hosted documentation ([2026-10-13](https://github.com/canton-foundation/canton-dev-fund/issues/817#issuecomment-6145265262)) and Eduardo found the documentation incomplete ([2026-10-14](https://github.com/canton-foundation/canton-dev-fund/issues/817#issuecomment-6168205227)), so we brought it forward to Milestone 2.

## Sources

* M2 release, v0.10.0 (2026-10-22), with its release notes and `canton-indexer-config.md`: <https://github.com/InfraDAO/canton-indexer-releases/releases/tag/v0.10.0>
* Node image: <https://github.com/orgs/InfraDAO/packages/container/package/canton-indexer-releases%2Fcanton-indexer>
* Grant proposal, as merged: <https://github.com/canton-foundation/canton-dev-fund/blob/2a822687b422aea1f168e92cce545e692f8b0ac1/proposals/canton-index.md>
* Milestone 2 issue: <https://github.com/canton-foundation/canton-dev-fund/issues/818>
* Release notes v0.6.0 (node, registry, deploy, document tables), v0.7.0 (atomic commit of documents and cursor, backoff), v0.8.0 (node runtime, `/metrics`), v0.9.0: <https://github.com/InfraDAO/canton-indexer-releases/releases>
* Filter format at v0.8.0 (record position, missing keys, coercions, `one_of`, privacy): <https://github.com/InfraDAO/canton-indexer-releases/blob/v0.8.0/docs/filter-format.md>
* Example `traffic-purchases.toml` at v0.8.0: <https://github.com/InfraDAO/canton-indexer-releases/blob/v0.8.0/examples/traffic-purchases.toml>
* cn-quickstart: <https://github.com/digital-asset/cn-quickstart>
* Splice LocalNet: <https://github.com/canton-network/splice/tree/0.7.4/cluster/compose/localnet>
* PQS (Participant Query Store): <https://github.com/digital-asset/participant-query-store>
