Hyperindex can run as a Tap-backed append-only indexer. In this mode, Tap verifies and orders AT Protocol repository events, and Hyperindex persists every valid Tap delivery to audit tables before updating the fast current-state record and actor projections used by normal GraphQL queries.
Use this mode when operators or downstream consumers need to answer both questions:
- What is the latest state? Query
records, typed collection fields, search, andcollectionStats. - What did the indexer observe over time? Query
auditRecordEventsor inspect the raw audit tables.
Enable append-only indexing only with Tap ingestion:
TAP_ENABLED=true
AUDIT_ENABLED=true
TAP_URL=ws://localhost:2480
TAP_ADMIN_PASSWORD=replace-with-your-tap-admin-passwordConfigure the Tap sidecar to decide which repos and records it should deliver:
TAP_SIGNAL_COLLECTION=app.certified.actor.profile
TAP_COLLECTION_FILTERS=app.certified.*,org.hypercerts.*AUDIT_ENABLED=true without TAP_ENABLED=true is invalid. Hyperindex fails startup because audit storage currently depends on Tap delivery semantics.
Tap is Bluesky's verified synchronization sidecar for AT Protocol repositories. Compared with the legacy Jetstream + backfill path, Tap gives Hyperindex stronger operational behavior:
- repo structure, MST integrity, and identity signatures are verified upstream by Tap
- events are processed with strict per-repo ordering
- ack-based delivery gives at-least-once behavior after crashes or reconnects
- identity updates are delivered alongside record events
- backfill and live follow are handled by one sidecar instead of separate workers
Append-only audit mode builds on those properties. Hyperindex commits audit history and current state in one transaction, then acknowledges Tap only after the commit succeeds when TAP_DISABLE_ACKS=false.
For each supported Tap record or identity delivery, Hyperindex performs one transaction:
Tap delivery
-> raw_tap_events append raw delivery bytes
-> record_events append deduped record create/update/delete event, when delivery type is record
-> identity_events append identity event, when delivery type is identity
-> record / actor update current-state projection
-> ack Tap after commit, unless TAP_DISABLE_ACKS=trueThe current-state tables remain intentionally small and query-friendly:
recordstores the latest known version of each indexed record.actorstores the latest known identity state for each DID.
The append-only audit tables preserve what Hyperindex observed:
| Table | Purpose | Public GraphQL access |
|---|---|---|
raw_tap_events |
Every successfully parsed Tap record or identity delivery, including duplicate deliveries. |
Operator/database access only. |
record_events |
Immutable record create/update/delete events, deduped by semantic event key. | auditRecordEvents query. |
identity_events |
Identity changes such as handle/status updates and purges. | Operator/database access only. |
The fastest local append-only test is usually: run Tap in Docker, run Hyperindex on the host.
Start Tap:
docker volume create hyperindex-tap-data
docker run --rm --name hyperindex-tap \
-p 127.0.0.1:2480:2480 \
-v hyperindex-tap-data:/data \
-e TAP_DATABASE_URL=sqlite:///data/tap.db \
-e TAP_ADMIN_PASSWORD=local-tap-password \
-e TAP_SIGNAL_COLLECTION=app.certified.actor.profile \
-e TAP_COLLECTION_FILTERS=app.certified.*,org.hypercerts.* \
-e TAP_DISABLE_ACKS=false \
ghcr.io/bluesky-social/indigo/tap:latestIn another shell, run Hyperindex against that Tap sidecar:
export TAP_ENABLED=true
export AUDIT_ENABLED=true
export TAP_URL=ws://localhost:2480
export TAP_ADMIN_PASSWORD=local-tap-password
export LEXICON_DIR=testdata/lexicons
export DATABASE_URL=sqlite:data/hyperindex-local-tap.db
export ADMIN_API_KEY="$(openssl rand -base64 32)"
export SECRET_KEY_BASE="$(openssl rand -hex 32)"
export EXTERNAL_BASE_URL=http://localhost:8080
make runCheck health:
curl -fsS http://localhost:8080/health
curl -fsS -u admin:local-tap-password http://localhost:2480/healthQuery recent append-only record events:
curl -fsS http://localhost:8080/graphql \
-H 'Content-Type: application/json' \
--data '{"query":"{ auditRecordEvents(first: 5) { edges { node { id receivedAt action did collection uri live } } } }"}'Stop local services:
docker stop hyperindex-tapRemove hyperindex-tap-data if you want Tap to start from a clean SQLite database next time.
Tap separates which repos to follow from which collections to emit:
TAP_SIGNAL_COLLECTIONtells Tap to discover and follow every repo with at least one record in that collection.TAP_COLLECTION_FILTERStells Tap which record collections to deliver for followed repos.
If you care about the signal collection records themselves, include the signal collection in the filters too. For example:
TAP_SIGNAL_COLLECTION=app.certified.actor.profile
TAP_COLLECTION_FILTERS=app.certified.*,org.hypercerts.*This tracks repos that publish app.certified.actor.profile and emits matching app.certified.* and org.hypercerts.* record events for those repos. Identity events are delivered for tracked repos regardless of record collection filters.
Current-state queries do not change when audit mode is enabled. Use existing queries for fast latest-state reads:
query CurrentProfiles {
appCertifiedActorProfile(first: 20) {
edges {
node { uri did rkey }
}
}
}Use the built-in auditRecordEvents query for append-only record history. It is a first-class public GraphQL query, but it is not generated from lexicons. There is intentionally no recordEvents query; record_events is the internal database table name.
For agents that support Agent Skills, install the Hyperindex append-only indexer skill to get query examples, field reference notes, pagination guidance, and lifecycle/delete handling patterns. If a consumer does not specify an indexer URL, the skill defaults to https://hyperindex-append-only-indexer.up.railway.app/ and uses https://hyperindex-append-only-indexer.up.railway.app/graphql for GraphQL requests.
npx skills add https://github.com/hypercerts-org/hyperindex-v2/tree/append-only-indexer/.agents/skills/hyperindex-append-only-indexerquery LatestAuditEvents {
auditRecordEvents(first: 20) {
edges {
cursor
node {
id
receivedAt
action
did
collection
rkey
uri
cid
rev
live
record
}
}
pageInfo {
hasNextPage
endCursor
}
}
}query RecordAudit($uri: String!) {
auditRecordEvents(
first: 100
where: { uri: { eq: $uri } }
orderBy: { field: ID, direction: ASC }
) {
edges {
cursor
node {
id
receivedAt
action
did
collection
rkey
uri
cid
rev
live
record
}
}
pageInfo {
hasNextPage
endCursor
}
}
}Variables:
{
"uri": "at://did:plc:alice/org.hypercerts.claim/abc123"
}query DeletedClaims {
auditRecordEvents(
first: 50
where: {
collection: { eq: "org.hypercerts.claim" }
action: { eq: DELETE }
}
orderBy: { field: ID, direction: DESC }
) {
edges {
node {
id
receivedAt
uri
did
rkey
rev
}
}
}
}query ActorAudit($did: String!) {
auditRecordEvents(
first: 50
where: { did: { eq: $did } }
orderBy: { field: ID, direction: DESC }
) {
edges {
node {
id
receivedAt
action
collection
uri
cid
record
}
}
}
}query AuditSince($after: String!) {
auditRecordEvents(
first: 1000
after: $after
orderBy: { field: ID, direction: ASC }
) {
edges {
cursor
node {
id
receivedAt
action
uri
cid
record
}
}
pageInfo {
hasNextPage
endCursor
}
}
}auditRecordEvents uses cursor pagination. Use the endCursor from one page as the next query's after value, and keep the same where and orderBy arguments between pages.
totalCount is opt-in. Hyperindex only runs the count query when totalCount appears in the selection set, and the value is the total number of events matching where, not just the current page and not just the rows after the cursor.
auditRecordEvents supports exact filters for:
idurididcollectionrkeyactionliverevcid
It also supports receivedAt equality and open-ended ranges with eq, gt, and lt.
Ordering is stable by append-only row id:
orderBy: { field: ID, direction: ASC }The default order is newest first.
The normal API smoke suite does not require audit history, because not every deployment runs with AUDIT_ENABLED=true.
Set HYPERINDEX_SMOKE_AUDIT=1 to opt into audit checks:
HYPERINDEX_SMOKE_URL=https://api.example.com \
HYPERINDEX_SMOKE_AUDIT=1 \
make smoke-apiThe audit smoke requires at least 5 audit record events by default. Override the minimum when an environment should prove more history exists:
HYPERINDEX_SMOKE_URL=https://api.example.com \
HYPERINDEX_SMOKE_AUDIT=1 \
HYPERINDEX_SMOKE_AUDIT_MIN_EVENTS=25 \
make smoke-apiA passing audit smoke proves that:
- the public schema exposes
auditRecordEvents - at least the configured number of audit record events exists
- returned audit rows have usable cursors and expected fields such as
id,receivedAt,did,collection,rkey,uri, andaction
Before treating a deployment as an append-only indexer, verify:
TAP_ENABLED=trueAUDIT_ENABLED=trueTAP_DISABLE_ACKS=falseunless you are intentionally debugging fire-and-forget deliveryTAP_SIGNAL_COLLECTIONdiscovers the repos you expectTAP_COLLECTION_FILTERSincludes every collection namespace you want indexed- lexicons are loaded before backend startup through
LEXICON_DIR, database registration, or uploaded lexicons - the deployment uses durable storage for both Tap and Hyperindex databases
HYPERINDEX_SMOKE_AUDIT=1 make smoke-apipasses after deployment
Tap delivery is at-least-once. Duplicate deliveries can happen after reconnects, crashes, or missed acknowledgements.
Hyperindex handles that by preserving raw deliveries and deduping semantic record events:
- every successfully parsed duplicate delivery still creates a new
raw_tap_eventsrow - duplicate semantic record events do not create duplicate
record_eventsrows - duplicate semantic record events do not update the current-state
recordprojection again
When Tap omits a repository revision, Hyperindex uses a weaker fallback event key based on Tap delivery id and a normalized payload hash so the delivery can still be committed and acknowledged.
Identity events are stored in identity_events. They update the current actor projection, and purge-style identity statuses remove current record and actor rows for that DID.
Identity purges do not create synthetic per-record delete audit events. The identity audit row is the reason current-state rows disappeared.
- Audit history starts when Tap audit mode starts. It does not reconstruct older edits that this indexer did not observe.
live = falsemeans Tap emitted the event from backfill or resync, not that the record is inactive.live = truemeans Tap emitted the event from the relay firehose after following the repo.- Deletes are permanent audit events even though the current
recordrow is removed. - Duplicate Tap deliveries are expected and are preserved in
raw_tap_events. - Identity purges are represented by
identity_events; they do not create synthetic per-record delete audit events. - Malformed or unsupported websocket frames are not guaranteed to appear in
raw_tap_events.