stable

coverage

Sketch-based read accounting: how much of a collection one principal has ever decrypted, emitted as a CoverageEvent when a declared fraction is crossed.
Subpath@noy-db/hub/coverage
FactorywithCoverage() → coverageStrategy
ClusterAudit

⚠️ Not the same thing as cover

Hub publishes both @noy-db/hub/cover and @noy-db/hub/coverage, and they are unrelated:

subpathwhat it is
/coverowner-curated plaintext metadata — name, description, icon — readable before unlock
/coverageread accounting — how much of a collection a principal has decrypted

The collision is named deliberately rather than renamed away, because renaming a published subpath is a breaking change for everyone already importing it. If you arrived here looking for the pre-unlock labels on a vault, you want cover.

What it does

Answers a question no access check can: this principal is allowed to read the collection — how much of it have they actually read? A viewer entitled to any single record is still worth noticing when they have decrypted 90% of the corpus.

Accounting happens at the decrypt point, and the state is sketches rather than a log:

  • a HyperLogLog per (principal, vault, collection) estimates distinct records ever decrypted — the coverage horizon, which never resets;
  • a Bloom filter per window estimates records novel to that window.

⭐ Both under-report by construction and never over-report. That direction is the point: an alert that fires late is a missed signal, while one that fires on phantom reads destroys trust in the alert.

Prerequisites

  • A corpusSize per collection you want a fraction for — coverage is distinct / corpusSize and the size is declared, not counted (see below). A collection without one still accumulates counts but can never cross a threshold.
  • Somewhere for the event to go — the signal is a coverage:threshold emission, so it is only useful if something is listening.
  • Nothing else. The sensor never reads the store.

Opting in

import { createNoydb } from '@noy-db/hub'
import { withCoverage } from '@noy-db/hub/coverage'

const db = await createNoydb({
  user: 'alice',
  secret: 'a-real-passphrase-2026',
  coverageStrategy: withCoverage(),
})

WithCoverageOptions tunes the sketches — precision (HLL registers, default 14 ⇒ 16 KiB per key), bloomBits (default 16384), bloomHashes (default 7) and windowMs (the novelty window, default one hour).

⛔ windowMs is not the coverage horizon. The window governs novelty; the coverage fraction accumulates for all time and never resets. Shortening the window makes bursts easier to see and does nothing to coverage.

Declaring a corpus, and why it is declared

// per collection
{ corpusSize: 12_000, alertAt: [0.6, 0.9] }

Coverage is distinct / corpusSize, and corpusSize is declared, not counted — two deliberate reasons, both worth keeping:

  1. Counting means a store read, on a sensor whose whole design is that it never touches the store.
  2. A corpus size that moves with every write makes an alert threshold un-reproducible: the same read pattern would cross 0.9 on Tuesday and not on Wednesday.

A collection that declares no corpusSize still accumulates distinct and served; it simply has no fraction, so CoverageStats.coverage is absent and no threshold can fire.

Each fraction in alertAt fires at most once.

The event

Crossing a threshold emits coverage:threshold carrying a CoverageEvent:

interface CoverageEvent {
  readonly principal: string   // keyring.userId the reads are attributed to
  readonly vault: string
  readonly collection: string
  readonly novel: number       // records novel to this window (Bloom-estimated)
  readonly served: number      // decrypts served to this principal, all time
  readonly coverage: number    // distinct-ever / declared corpus, 0..1 (HLL)
  readonly window: string      // ISO-8601 start of the observing window
  readonly source: string      // which sensor produced it — 'hub/coverage' here
}

⭐ CoverageEvent is a published seam, not an internal shape. Two consequences for anyone building on it:

  • source is a string, never an enum, and values are shaped <publishing package>/<sensor> — the package, not the repo. An enum would make every new sensor a breaking change to hub.
  • New sensors add fields; they never redefine served / novel / coverage / window. Mixed horizons across sensors are by design, so read source before comparing two events' numbers.

Limits worth knowing before you alert on it

  • The estimates are sketches. Treat a crossing as a threshold was reached, never as an exact count.
  • Accounting is per (principal, vault, collection). One principal reading broadly across many collections crosses nothing if each collection stays below its fraction — alert on the pattern you care about, not only on this one.
  • ⚠️ An eagerly hydrated collection decrypts its whole corpus when it loads, so a decrypt-point sensor sees full coverage immediately. That is honest reporting of what happened, and it is not the signal you wanted; the eager case is handled in hub's accounting rather than by ignoring it, but a threshold on an eager collection tells you about hydration, not about a reader.

See also

  • cover — the similarly-named, unrelated pre-unlock metadata subpath
  • tiers — the access controls coverage complements: entitlement versus volume
  • session-tiers — the session side of the same question, per operation rather than per corpus