06 — Data model
How a record is built
Everything Prometheus stores and publishes is a row of the same shape: which project, which version, which cohort, which time window — and a value. For the reporting modes A to C that value is a band rather than a number; only mode D carries plain numbers, and only from public registries. The schema has no fields for people, hosts, or sessions — they are not left empty, they do not exist.
Key
The aggregate key
(project_id, version_id, cohort_id, window_start) — nothing more
is needed to identify an aggregate. What the key leaves out is as much part of
the statement as what it contains.
This is how a row comes about: from raw events that only ever live in memory, four key columns are set, the measured value is mapped onto a band, and only after the k-check does the result leave the machine.
- project_id pkg:npm/example-lib
- version_id 1.4.2
- cohort_id rt-runtime-mins
- window_start 2026-05-26T12:00:00Z
k_effective were below 5, the same row would
be rewritten locally into __small_cohorts — before sending, not
after.
project_id — the PURL
Always a package URL, never free text: pkg:npm/example-lib,
pkg:pypi/sample-pkg, pkg:maven/<group>/<artifact>,
pkg:cargo/…, pkg:golang/…. That keeps
pkg:npm/foo cleanly apart from pkg:pypi/foo.
version_id — the measured version
The version a measurement refers to. Only with it does the question become answerable whether a migration is actually landing in the field or whether an old major version keeps running.
cohort_id — the category
A predefined category, not a freely coined key — such as
rt-runtime-mins, install-count, or
dep-edges-from-public-manifests. New cohorts are published in
the federation standard, not invented ad hoc.
window_start — the time window
Start of a tumbling window, UTC-aligned. Timestamps finer than the window granularity are forbidden by the schema — they would amount to a behavioural profile.
What is not part of the key: there is no tenant_id —
the platform knows no tenants because it has no customers. And
source_mode (A, B, C, D) is a column of its own, not a key component:
the same cohort seen by the SDK and by the registry scraper sits side by side on
purpose, instead of one overwriting the other. So the key groups, it does not
enforce uniqueness — two rows with the same key and a different
source_mode are two measurements of the same thing from two sources.
Wire format
How a submission is built
A submission is a canonically serialized JSON document with a window, a submitter identity, a signature, and a list of aggregates. What is not in here is not something a mirror gets from the submitter either — its own technical metadata and assessments it adds itself on ingress.
{
"schema_version": "1.0",
"submitter_id": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH",
"signature": "ed25519:5K3PqL…",
"window_start": "2026-05-26T12:00:00Z",
"window_end": "2026-05-26T13:00:00Z",
"aggregates": [
{
"project_id": "pkg:npm/example-lib",
"version_id": "1.4.2",
"cohort_id": "rt-runtime-mins",
"metric": "runtime_minutes_band",
"value": "30-60", // a band, not a number
"k_effective": 23, // actual cohort size
"k_min": 5, // required floor
"source_mode": "A" // capture mode, a column not a key
}
],
"attestation": {
"kind": "declarative",
"claims": ["k>=5", "no-pii-fields", "window-monotonic"]
}
}
The example is a reading view of the structure, not the signed byte sequence:
canonical JSON has no // comments, and JCS sorts the keys
lexicographically. What gets signed is the result of that canonicalisation, not
what is printed here.
Canonical bytes — RFC 8785
Sorted keys, no whitespace variants, UTF-8. The same input produces byte-identical output in every SDK language — without that, no signature would be verifiable across languages.
Signature — ed25519, no prehash
Signing happens directly over the canonical body bytes. The submitter
identity is a did:key: a self-describing key, with no
registration at any authority.
submission_hash — idempotency
sha256 over the same canonical body, but separate from the
signature and never an input to it. It is the key in the public audit log:
the same submission sent twice creates no second entry.
attestation — the assurance
The submitter states which conditions it upheld locally. In Phase 1 that assurance is declarative; from Phase 2 a cryptographic k-anonymity attestation takes its place.
Fields
The fields in detail
In modes A, B, and C the upper group comes from the submitter and is part of the signed bytes. Mode D has no submitter: there the mirror's own scraper fills the same columns mirror-internally and without a signature roundtrip. The lower group is set by the mirror in every case on ingress — it is assessment, not a claim by the submitter, and therefore deliberately unsigned.
| Field | Example | Meaning | Set by |
|---|---|---|---|
| From the submitter — from the mirror's scraper in mode D | |||
| project_id | pkg:npm/example-lib |
Measured project as a PURL. Key component. | Submitter (A–C) · scraper (D) |
| version_id | 1.4.2 |
Measured version. Key component. | Submitter (A–C) · scraper (D) |
| cohort_id | rt-runtime-mins |
Predefined category. Key component. | Submitter (A–C) · scraper (D) |
| window_start / window_end | 2026-05-26T12:00:00Z |
Tumbling window, UTC-aligned. window_start is a key component. |
Submitter (A–C) · scraper (D) |
| metric | runtime_minutes_band |
Which quantity was measured. Determines which bands apply to value. |
Submitter (A–C) · scraper (D) |
| value | 30-60 |
Band label, not a plain number — the exception is mode D on public sources. | Submitter · the scraper for D |
| k_effective | 23 |
Actual cohort size behind the value. Empty for mode D, because a public registry has no cohort. | Submitter · not applicable to D |
| k_min | 5 |
Required floor. Default 5; at least 25 in the end-user-facing modes B and C. | Submitter · not applicable to D |
| submitter_did wire field: submitter_id |
did:key:z6Mk… |
Submitter identity as a self-describing key — an instance, not a person. | Submitter · not applicable to D |
| source_mode | A |
Capture mode A, B, C, or D. A column, not a key component. | Submitter · the scraper for D |
| Set by the mirror on ingress | |||
| submission_hash | 244be0…3df0 |
64 hex characters over the canonical bytes. Idempotency key and pointer into the audit log. | Mirror |
| received_at | 2026-05-26T13:04:11Z |
Arrival time at the mirror. Deliberately separate from the measurement window. | Mirror |
| trust_tier | new → established |
New submitters go through a seven-day burn-in before their data counts as established. | Mirror |
| plausibility | ok |
Anomaly marker (track-a-spike, track-d-missing). Nothing is deleted, it is annotated. |
Mirror |
| provenance | registry-scraper@mirror-eu-1 |
Origin for mode D: which scraper of which mirror produced the row. | Mirror |
| source_schema_version | 1.0 |
Major version of the submission schema the row came from. | Mirror |
The four annotation columns — trust_tier, plausibility,
provenance, and source_schema_version — are assessment output and simply do not exist in the wire
format: a submitter can neither rate itself as trustworthy nor rewrite its own
provenance.
Windows & bands
Banding instead of exact numbers
Two dials decide whether aggregates can be turned back into a profile: how finely time is resolved, and how precisely the value is stated. Both are set coarse on purpose.
| Window | Used for | Visible in |
|---|---|---|
| 1 min | local aggregator only, for debugging | never in a submission |
| 1 h | default for the self-instrumentation SDK (mode A) | submission, API, snapshot |
| 1 d | default for registry scraping (mode D) | mirror-internal record, API, snapshot |
| Cohort / metric | Bands |
|---|---|
rt-runtime-minsruntime_minutes_band |
0-5 · 5-15 · 15-30 · 30-60 ·
60-180 · 180-1440 · 1440+ |
install-countinstall_count_band |
1 · 2-5 · 5-25 · 25-100 ·
100-1000 · 1000+ |
dep-edges-from-public-manifestsobserved_dependency_count |
A plain number is permitted — mode D evaluates public manifests only, so there is nothing to protect here that is not already public. |
Here is the same structure when it comes from registry scraping: no
k_effective, because there is no cohort, but a provenance entry and
a daily window instead.
{
"project_id": "pkg:pypi/sample-pkg",
"version_id": "2.0.0",
"cohort_id": "dep-edges-from-public-manifests",
"metric": "observed_dependency_count",
"value": "1247",
"k_effective": null,
"k_min": null,
"window_start":"2026-05-26T00:00:00Z",
"window_end": "2026-05-27T00:00:00Z",
"source_mode": "D",
"provenance": "registry-scraper@mirror-eu-1"
}
Absences
What the schema does not know
The following are not optional, not switchable, and not "internal only" — there is simply no field for them. An SDK bug that tried to send such a thing produces a rejected submission, not a record.
Two gates, not one
The local aggregator checks before anything leaves the machine, and the mirror checks again on ingress. Both checks are equally strict — the second exists for the case where the first was bypassed.
__small_cohorts
If a cohort stays below k_min, it is neither dropped nor
quietly padded, but rewritten into a collective bucket. The value still
counts towards the sum; the cohort disappears.
Rejection without a trace
If a submission violates the signature, the schema, the k floor, or window logic, the mirror answers with an error code — and persists nothing. Not even for debugging.
Window plausibility
window_start < window_end, and the window must not lie in the
future; five minutes of clock drift are tolerated. That rules out post-dated
submissions. The check has no lower bound so far — an old, correctly ordered
window satisfies it.
Snapshots
Publication as Open Data
Whatever a mirror accepts gets published — no login, no paywall, and no second, richer edition for paying third parties. There is only this one body of data.
| Property | Value |
|---|---|
| Schedule | daily at 00:00 UTC |
| Formats | Parquet as the primary format, plus CSV.gz — usable without Pandas or any other tooling |
| Variants | raw holds the raw aggregates and is the canonical edition
for auditing; resolved merges project aliases. Both are
produced daily; __small_cohorts is never resolved by name,
not even in resolved. |
| Integrity | Both variants carry the same SHA256 manifest entry of the underlying
data state plus the hash of the alias table used — including the
raw variant, so it stays provable from the audit side
what the resolution was made against. |
| License | CC-BY-4.0 as a provisional decision; while legal clearance is pending, every snapshot carries a provisional clause in its manifest |
| Retention | unlimited — older snapshots are archived, not deleted |
Alongside the snapshots, the same structure is available live through the aggregate API and the event stream, likewise without a login. Anyone wanting to check whether two mirrors say the same thing compares their published audit log heads.