Content Telemetry projection contract
This contract is licensed under CC-BY-4.0 (docs/contracts/LICENSE).
- Owner: Network. The relay’s spool and delivery are the Edge’s code, described here because they decide what a receiver is sent and when.
- Producer: the edge’s relay (
crates/commonmeasure-relay), from the source record (session evidence) and from published runs named to it (run output). - Consumer: the receiver named in
relay.json: the operator’s hub once the edge is enrolled (enrolment), or any receiver that accepts a Content Telemetryevent_batch. The hub delivers each content owner’s events onward (onward delivery). - State: built. Projection, spooling and delivery are
fixture-tested: recorded inputs through the real relay transport to a loopback receiver (§Verification). The ignored tests incrates/commonmeasure-cli/tests/instance_e2e.rsdeliver to a running hub; no recorded run of them is kept here. Acceptance by an independent conforming receiver running its current code is not recorded (NET-6).
The source record is private. The relay selects from it, under the operator’s
clearances, the events Content Telemetry defines, and sends them to one
receiver. Nothing else in the record leaves the machine. Terms such as
crossing, engagement, scope and receiver are defined in
docs/GLOSSARY.md.
What is projected
Section titled “What is projected”The relay emits content_retrieved, content_grounded, content_cited,
turn_started and turn_completed. A citation comes only from a host’s
output observation (§Citation from host observations). It emits no
presentation, reproduction or engagement claim. The pinned schemas are listed
in schema/SOURCE.md; crates/commonmeasure-relay/src/project.rs implements
the selection, crates/commonmeasure-relay/src/wire.rs the wire types, and
conformance/ freezes the emitted batches.
A batch is an event_batch document of one session, schema_version 1.0,
with at most 500 events:
| Member | Value |
|---|---|
session_id |
the session identifier where it is a UUID, otherwise a UUID derived from it; a run’s identifier for a run |
agent_id |
the key_id of the session’s first edge_identity record, or commonmeasure, pinned per session at first delivery (§The agent id on the wire); always commonmeasure for a run |
started_at |
the earliest event’s timestamp |
refused |
the session’s refused count (§The refused count on the wire); absent on a run’s batches |
events |
the events |
Each event carries id, type, timestamp (UTC, milliseconds) and
source_role agent. An event’s id is a UUID derived from the record it
projects (the session, the record’s position in the log and the event kind;
for a run, the run, plan and rank), so a redelivery carries the same id and
a receiver counts it once.
content_retrieved, one per cleared crossing:content_url(the crossing’s URL without user information);license_refwhere the crossing’s licence isdeclared, also without user information, since a licence URL resolved against the page’s URL keeps any the page URL carried;content_telemetry_id(below);instance(§Instance reference). A crossing that handed a file over unread (session evidencedelivered_file) also carriesdata.commonmeasure-delivered-file(§Custom fields) and projects no grounding of its own; a host’s context-entry observation of it projects one only under the file’s own hash (§Grounding from host observations).content_grounded, for a crossing that grounded:data.scopesession,data.content_hashand the ingestion measure (§Ingestion measure). Where the crossing records the part of the extracted text its fetch result carried (session evidencedelivered),data.content_hashis that part’s hash,delivered.hash, the same texttokens_ingestedcounts; for a page delivered whole it equals the crossing’scontent_hash. A crossing that records a part without a validdelivered.hashcarries nodata.content_hash. A crossing that records no part (search results, and logs from before parts were recorded) carries itscontent_hash, where that is a validsha256:hash, and none otherwise. The part is what the edge handed the host, and, as session evidence says, not evidence of what the model read. The hash is neverretrieved_hash, the hash of the body as served. A crossing whose context entry the host observes is grounded by the observation instead, withdata.scopeturnand the observation’s representation hash (§Grounding from host observations).content_cited, for a source a host observed an answer naming by its URL or handle:output_id,turn_idanddata.citation_type(§Citation from host observations).turn_started,turn_completed: a turn boundary (§Selected coverage).- A run named to the relay projects each admitted source with a URL as a retrieval, and as a grounding where it has a content hash, all at the run’s start time.
content_telemetry_id is the UUID a mediated fetch sent as its
Content-Telemetry-ID request header (Content Telemetry section 7.2,
session evidence §Source declarations),
so an owner that logged the header can match the retrieval event to its own
log line. The relay carries it on the retrieval event and on no other.
Custom fields
Section titled “Custom fields”Content Telemetry allows an emitter’s own members in an event’s data. The
relay adds these, and two members outside data, which the pinned schemas
admit because they do not close their objects:
| Field | Where | Value |
|---|---|---|
data.commonmeasure-projection |
every event | the projection’s conformance level and coverage (§Selected coverage) |
data.commonmeasure-supplier |
retrieval and grounding events of a search result or a run’s plan | the supplier that served it (exa, ozone, …); withheld for the operator’s own corpus (internal) and for skills (skill:<name>) |
data.contextops-host-tool |
session events | the host word (session evidence §Client identity); on retrieval and crossing grounding events, that of the session’s first witnessed crossing, for the whole session; on turn events and observation grounding, the record’s own |
data.token_basis |
grounding events with tokens_ingested |
the recorded basis of the count (§Ingestion measure) |
data.commonmeasure-output-associations |
turn_completed with an output observation |
{"count": n} (below) |
data.commonmeasure-crawl-delay |
retrieval events of a mediated fetch sent under a host’s Crawl-delay above zero |
{"delay_ms": n}: the delay this edge kept for the host that answered (the reading’s honoured_ms, at most 60000), read from the delay ruling on the crossing’s robots evaluation (session evidence §Source declarations). The ruling can be read when its host matches the host that answered, its delay_ms is an integer from 1 to 60000 and its outcome is clear or waited; other members of the ruling are not read, and their values do not affect the field. Absent where the group states no delay, states Crawl-delay: 0 or a value that cannot be read, where the ruling is missing or cannot be read, on every other event and on a run’s events. The hub forwards data onward, so a content owner receives its own host’s delay on its own retrievals |
data.commonmeasure-delivered-file |
retrieval events of a crossing whose delivered_file is an object |
{"content_type": "application/pdf"} where the crossing’s content_type is one the edge writes for a delivered file (today application/pdf alone), otherwise {}. Nothing else from the record is carried: not via, bytes or statement, and not breach. It is keyed on delivered_file, never on content_type or breach text. It records that the edge handed the bytes over unread and counted nothing, so the crossing projects no grounding of its own; the file is grounded only by a host’s context-entry observation under the file’s own hash (§Grounding from host observations). Content Telemetry v1.0 has no MIME field; media_type names a medium, text by default, and the relay sets it on no event, a file’s included |
instance |
retrieval and grounding events, beside data |
the instance reference, to the issuing hub only (§Instance reference) |
refused |
the batch, beside events |
the session’s refused count (§The refused count on the wire) |
data.commonmeasure-output-associations.count counts the associations of a
host’s output observation
(session evidence §Output-association observations)
whose acquisitions and context entries pass selected coverage and privacy
checks, and, for a delivered file, whose context entry names the file’s own
hash (§Grounding from host observations). It is distinct from the number
of content_grounded events, and a receiver counts it once per boundary
event ID. It is absent on boundaries
without an output observation, so a missing observation never becomes a
measured zero. It counts the boundary’s references; the standard events for
the same references are the content_cited events of §Citation from host
observations, one per distinct source. The
boundary needs its own clearance and at least one eligible source event, as
§Selected coverage requires.
Selected coverage
Section titled “Selected coverage”commonmeasure:telemetry-selection:v1 is the opaque reference for the selection
rule in this section. Coverage is selected for each emitted event type;
the relay never declares complete coverage. Each event carries the following
informational declaration in the schema’s extension container:
{"data": {"commonmeasure-projection": { "conformance_level": "citation", "coverage": {"mode": "selected", "terms_ref": "commonmeasure:telemetry-selection:v1"}}}}conformance_level is citation on a session’s events and grounding on
a run’s. A session’s projection emits content_cited with the members
Content Telemetry v1.0 section 5.7.3 requires (id, output_id,
data.citation_type, a non-null content_url) on top of the Grounding
events; a run records no answer and cites nothing. The level states what
the projection emits when a host observes a citation. Coverage stays
selected: a session whose host sends no output observation reports no
citation, and that absence is not a measured zero.
The extension describes the event’s projection. It does not add a standard
batch field or replace an emitter manifest. An emitter manifest advertising
this projection uses telemetry.conformance_level: citation and a
telemetry.coverage entry for each of the five event types, with
mode: selected and the same terms_ref. Manifest publication belongs to the
participant operating the endpoint; the relay does not publish one.
The relationship scope is the source activity selected for the operator’s
configured receiver. A witnessed session crossing is cleared by its recorded
working directory. Without a directory selection, the directory must match a
policy scope that names a governing engagement and sets
allow_telemetry_egress (session evidence §Crossing).
With one, the directory’s enrolment and current consent decide, a matched
scope that sets the flag false vetoes (§Directory reporting consent). In
either case a directory the policy cannot resolve safely does not clear: an
unbound principal, a scope owned by another principal, or a symbolic link or
linked worktree that resolves to another scope. Managed reporting approvals also apply
where directory reporting is configured. A cleared crossing
whose host falls under operator terms naming institution identifiers
(access_context) is withheld, and the rest of its session is projected as
its clearance says (session evidence §Source declarations); the edge’s reporting ruling reads the same predicate and leaves a licence’s reporting demand unmet where this hold would apply. A crossing made under the anonymous access profile, which carries anonymous (session evidence §Crossing), is never cleared, in any scope and under any consent: nothing of it is projected, its refusal is left out of the refused count, and the edge’s reporting ruling leaves a reporting demand on such a host unmet (product decision “Anonymous access profile”; crates/commonmeasure-harness/src/egress.rs under_anonymous_profile). Event ids are derived from positions in the log as read, so a held crossing keeps its id and the ids around it do not move. A run explicitly named to the relay contributes its admitted
sources. Both paths exclude private addresses and named internal prefixes.
A session also excludes crossings marked internal, reconstructed crossings,
failed fetches and non-2xx responses. Refused sources leave only as the existing
session count; rejected run sources do not leave. Prompts, answers, evaluator
output, costs and private record detail are excluded. Unobserved host activity
and records missing a usable URL or timestamp cannot establish an event.
These exclusions mean absence of an event cannot establish absence of use.
One crossing is cleared without a scope’s clearance: a witnessed crossing
whose licence demanded reporting and which the edge admitted under the
operator’s reporting consent, recorded as declarations.reporting with
met true and consent.state agreed (session evidence §Reporting
consent; owner decision, 27 September
2026). Where its scope clears nothing, or sets allow_telemetry_egress: false, the consent clears that crossing alone: its content_retrieved and
content_grounded events, including grounding from a host’s observation of
it. The session’s turn boundaries, its other crossings and its refused count
keep their scope’s clearance, so a session that leaves under consent alone
carries the demanded crossings and nothing else (§The refused count on the
wire). The relay reads the consent from the crossing’s record, not from
consent.json at the run: the edge admitted the crossing on the condition
that it is reported, and a withdrawal applies to crossings after it and
recalls nothing. A record made before the consent existed carries no
consent and is not cleared by it. Every other exclusion above applies
unchanged, and the relay’s summary names these events under “reporting
consent”. Queued directory batches are rechecked under the same rule
(§Directory reporting consent).
A turn boundary requires its own clearance, resolved from payload.detail.cwd.
It accompanies a session only when at least one source event is eligible.
A neighbouring public crossing never clears a private boundary. The projection
copies the recorded turn_id where supplied, normalises the timestamp and
carries turn: {"privacy_level": "minimal"}. The hub reads turn as a
closed type holding privacy_level alone, so a hub that predates a new
member of turn answers 400 to every batch that carries it; a change to
turn lands in the hub first. It excludes the working directory,
transcript path, privacy rationale and all conversation content. A boundary
without a recorded minimal declaration or usable timestamp is excluded; the
relay does not invent missing host boundaries or turn identifiers. Turn event
IDs derive from the session, original log position and boundary kind, so
changed clearance cannot renumber previously delivered events.
Instance reference
Section titled “Instance reference”A content_retrieved or content_grounded event whose source record carries
the instance reference (session evidence §Where) carries it as an event-level member, beside
data rather than inside it:
{"instance": {"issuer": "https://hub.example", "id": "5c2f8d8e-7a41-4a0b-9e55-1f6b3c9d2e70", "revision": 1}}issuer is the registration service’s origin without a trailing slash, id
the instance identifier and revision the integer binding revision held when
the source record was written. Each event takes the reference of its own
source record: a crossing’s events take the crossing’s, and a grounding event
projected from a host context_entered observation takes the observation’s.
Records written after a renewal therefore carry the later revision, and events
projected from earlier records keep the earlier one.
The member is not a standard Content Telemetry field. It is projected only when the origin (scheme, host and port) of the receiver the relay is projecting for equals the recorded issuer: after enrolment the configured receiver is the hub’s origin followed by its telemetry path, and the issuer is that origin. For any other receiver the member is absent and the batch is otherwise identical. The hub uses the member to join the event to the instance’s reporting duties and removes it before onward delivery (tested in the hub’s own repository, not here).
No member is projected for a record without a reference, for a reference that is not a string issuer, a non-empty string identifier and an integer revision, for a turn boundary, or for a published run, whose summary carries no reference. A session with no registration projects exactly what it did before this member existed. Absence of the member claims nothing about registration. Event identifiers do not depend on it, so an event delivered before this member existed is not delivered again and its duty is not joined by it.
The issuer check is made when the batch is projected and again on the
document each delivery posts. A batch can wait in the spool across a change of
receiver (--receiver, or an enrolment with another hub rewriting
relay.json); delivery removes the member from every event whose issuer is
not the origin of the receiver it is posting to, beside the agent_id
rewrite. A batch carries no digest or signature over its events, so nothing
is invalidated. The spooled file stays as queued, so a later delivery to the
issuer still carries the member. A batch projected for a receiver that was
not the issuer has no member to restore, and a later delivery of it to the
issuer joins no duty.
Delivery state is kept by event identifier, whatever the receiver
(§Delivery state). Events another receiver accepts are recorded delivered and
are not sent to the issuer by a later run, so the issuing hub reads nothing
for them and a reporting duty of that instance cannot be met from them. The
relay’s report counts the members delivery withheld from batches that held
one (RelayReport.instance_references_withheld), commonmeasure relay
prints a warning when the count is not zero, and a dry run forecasts the
same. A run in which another batch failed carries the same count on its
failure (DeliveryFailure.instance_references_withheld) and the command
prints the warning after the failure, because the accepted batches’ events
are recorded delivered whatever happened to the rest. The count does not cover events projected for a receiver that was not
the issuer, which never carried the member.
One receiver
Section titled “One receiver”The relay posts each batch to the receiver with its trailing slashes
removed, followed by /events, and parses that URL with the WHATWG URL
rules. Two receiver URLs are one receiver when the URLs posted to are equal
after that parse (commonmeasure_harness::relay_config::same_receiver).
Status uses this comparison to say whether the last accepted delivery went to
the receiver now configured. These spellings are one receiver:
| Difference | Example | Handled by |
|---|---|---|
| Trailing slash, one or more | /telemetry/, /telemetry |
the /events derivation |
| Empty path | https://r.example, https://r.example/ |
the URL parse |
| Scheme or host case | HTTPS://R.Example/t |
the URL parse |
| Explicit default port | https://r.example:443/t |
the URL parse |
| Dot segments | /x/../t, /./t |
the URL parse |
| Host spelling | IDNA names, IPv4 and IPv6 address forms | the URL parse |
| Credentials in the URL | https://user@r.example/t |
not sent by the transport |
| Fragment | https://r.example/t/events#x |
not sent by the transport |
| Trailing dots on the host, one or more | https://r.example./t, https://r.example../t |
removed for the comparison only |
A host with its trailing root dot reaches the same server: the transport
resolves the absolute name, sends it in Host as written, and the TLS
client drops the dot from the server name and matches the certificate. The
comparison removes every terminal dot, reading the host as a crossing’s host
is read (commonmeasure_harness::grounding::host_of). More than one is not a valid DNS
name, and treating two spellings as one receiver can only apply a scope. A different scheme, port, path, path case or query
is another receiver.
relay.json is refused at load when its receiver URL has a query or a
fragment (the /events suffix would land inside it rather than on the
path), credentials (the transport never sends them; the key belongs in
api_key), a scheme other than http or https, or no host. A
--receiver override is not refused for these; it is compared as above, and
one the transport cannot post to matches nothing.
The load error names the fault and the receiver’s origin (scheme, host and
port, such as https://hub.example:8443), and no other part of the URL: a
key can sit in the credentials, the query or a tokenised path, and the error
reaches status, doctor, directory status, the source record and, as the
reason for an unmet reporting demand, the agent. A receiver that is not a
URL is named by no part of itself. status and doctor name the receiver of
the last recorded delivery by its origin in the same way, or as “an earlier
receiver” where it has none, and show a recorded delivery error that quotes a
receiver URL without the URL: relay/receipts.json written by 0.4.1 holds
the receiver as it was configured, and a --receiver override is kept as
given. A host can itself be a capability, since
some webhook services put the token in the host name, so for such a receiver
the origin shown is sensitive too.
The comparison is by URL. Two host names that resolve to one server, such
as localhost and 127.0.0.1, are two receivers to it.
Supplier reporting belongs to the hub: a supplier is to receive only the
events it served. The relay carries no per-supplier receiver. The relay sends
every cleared event to the one receiver relay.json names and does not divide
them by supplier.
The same comparison decides whether a receiver is a licence’s reporting
endpoint itself: the URL the relay posts to, the receiver followed by
/events, equals the endpoint after the parse above. The endpoint is the URL
events are posted to, so nothing is added to it, and a receiver copied from
the endpoint posts under it, to another path. A reporting demand is met only
through that endpoint or the hub the edge is enrolled with (session
evidence §Source declarations).
Ingestion measure
Section titled “Ingestion measure”A grounding event carries tokens_ingested only when its source record has a
non-negative integer count and a non-empty recorded token_basis. The relay
copies both, preserving token_basis as a custom event data field retained
from the existing wire format. It is not a standard field. Session counts come
from payload.estimated_tokens and payload.token_basis; published run counts
come from each admitted source’s tokens and run.token_basis in summary.json.
The projection never substitutes a current estimator for a missing recorded
basis. A grounding event projected from a host context-entry observation
carries neither. An absent count or basis leaves both fields absent, and a recorded zero
with a basis remains zero. characters/4 and whitespace-words are estimates,
not model-tokeniser counts or measurements comparable between emitters.
The relay does not emit chars_ingested: it reads hashes and counts from the
record, not the captured text. Multiplying a rounded token estimate would not
recover the Unicode code points placed in context. A crossing’s grounding
carries in content_hash the hash of the text its count covers: the part a
fetch result carried, which may be the whole text (§What is projected). A
grounding from a host observation carries the host’s hash and no count
(§Grounding from host observations). Page text never enters telemetry.
Delivery state
Section titled “Delivery state”The relay retains each projected batch in relay/spool/outbound.ndjson.
queued_at records when it entered the spool, and each line carries its spool
index as its first member. Private metadata records queued,
delivered or dead, the attempt count, next attempt time, last attempt time,
last error and a separate hold_reason for each spool index. These fields
never enter the Content Telemetry document. A batch with no metadata is queued
and unclaimed.
The metadata is a snapshot and a journal. Each state change is one line
appended to relay/spool/outbound.delivery.journal and made durable before
the operation returns; the line carries the batch’s whole state, so replaying
it twice changes nothing. When the relay or relay requeue closes the spool it
folds the journal into relay/spool/outbound.delivery.json, which then holds
every retained batch, and replaces the journal with a header carrying the next
generation, the next spool index and the count of pruned deliveries. After a
clean close the journal holds its header alone; a spool that has never
recorded a state change has no journal. A reader reads the journal before and
after the queue and snapshot and reads again if the generation changed, so it
never combines one generation’s snapshot with another’s journal. It also reads
again when the journal states a batch its copy of the queue lacks, which a
writer that enqueued and claimed during the read produces. After eight
attempts the read fails with an explicit error, and status reports delivery
state as unavailable with unknown counts. A relay stopped before it closes
leaves the journal in place; the next reader and writer replay it, and the
next clean close folds it.
An enqueue or journal line cut short by a crash was never reported as
written, and the next writer removes it. An unterminated final queue line
that parses as JSON was not cut short, because no strict prefix of a line
parses: it is checked like every other line. A whole entry is given its
newline, since a restored file may end that way; if it was an interrupted
enqueue, its event ids count as spooled and nothing is projected twice. Any
other such line is refused or reported as damage, as below, and never
removed. Every queue line is
checked to be a whole entry, and every journal line parsed, whenever the spool
is read, so damage before the last line of either file is an explicit error
from the relay, relay requeue, a dry run and status, and no line is skipped
or removed. One damaged queue line therefore stops the delivery of every
queued batch until it is repaired. Removing the line does not repair it: the
metadata still names its index. The number in the report is the batch’s spool
index, the line’s index member. The batch’s metadata is the snapshot
member and the journal records that carry that index. For a damaged line of a
delivered batch, replace the line with a placeholder that keeps its index, for
example
{"index":7,"origin":"damaged line replaced","directory_selection":false,"document":{"events":[]}} for
spool index 7; the spool then opens, delivery resumes and the placeholder is
pruned under the retention rule below. For a damaged line of an undelivered
batch the payload is lost. While no relay runs, remove the line, or empty it
and keep its newline, and remove the batch’s entries from the snapshot and the
journal. Every other line names its own index, so no other batch moves. Then
run the relay, which projects the events again where the session log still
reads and the clearance in force admits them. A damaged line that has lost the
start of its index member is reported as naming no index, and a line whose
index is not above the one before it as out of order, each by its line number
counted from zero; the remedies are the same, and where the snapshot or the
journal has an entry for the batch, its index is the one no other line
carries.
Metadata left for a line that was emptied or deleted is reported as naming
batches the queue lacks. A journal append that fails is cut back to the last
durable record; that process records no further change and its close folds
and prunes nothing. A close that cannot fold the journal loses no state and
writes its reason to relay/spool/outbound.close-error, which status reports
as close_error until a later close succeeds; writing the reason is best
effort. The file is read for status alone: its first 4 KiB, with bytes that
are not UTF-8 shown as replacement characters and control characters as
spaces. A file that does not read is reported as close_error_unreadable
with close_error null, because it does not show whether the close failed;
removing it is safe. Neither stops a relay run, relay requeue or the status
counts.
Delivered batches are pruned at close. A delivered batch is released once its
accepted attempt is 30 days old, or once more than 1,000 delivered batches are
retained, lowest spool index first; the second rule also bounds deliveries
whose time is unknown. Index order is enqueue order: a batch requeued and
delivered today is released before a newer batch delivered last week. Thirty
days is therefore not a minimum: an edge that delivers more than 1,000 batches
in the period keeps less. Private batch metadata records
instance_references_withheld, the number of instance members the accepted
attempt withheld (§Instance reference); the field is absent until the batch is
accepted, and may be absent on an older accepted batch, when delivery withheld
no member. A batch with a count above zero
is released by the 30-day rule only, because its retained payload is the only
spooled copy that carries the member for the issuing hub, and a remedy that
sends it from the spool needs a period that does not shrink with delivery
volume. After release the member can be recovered only by projecting the
session record again. Two exemptions apply to released batches. A delivered
batch whose session has no pinned agent identifier
(relay/session-agents.json) is kept, because its payload is the only record
of the identifier first sent. The remaining batches are removed when they make
up at least one eighth of the retained batches, so the queue file is not
rewritten for every delivery. Queued, held and dead batches are never pruned.
The journal names the batches to prune before any file loses them; a crash
part-way is completed from that line, and readers treat the named batches as
pruned meanwhile. A pruned batch’s payload and metadata are gone from the
spool; its event ids stay in delivered.idx, its source stays in the session
record, and it stays in the delivered batch count. Only the journal holds the
count of pruned deliveries. Before the queue first loses a batch, a prune
writes relay/spool/outbound.pruned, which carries no count and which the
relay never removes; a manual recall of the spool removes it with the journal,
or the emptied spool’s count reads unknown. If the journal is missing beside
that file, or beside a queue whose indices have a gap, the delivered batch
count is unknown, not the retained part of it, and stays unknown. The file
matters where the queue shows nothing: after a prune of every batch, or of the
newest batches alone. A spool pruned only by a version from before the file
has the gap as its one sign, so in those two cases it reads zero once its
journal is missing. A spool index is never given to a second batch while the
journal exists.
When relay/spool is moved aside, the next relay run that names no
--session projects again, from every session log under sessions/ that
reads and under the consent, approvals and policy in force, every event
relay/delivered.idx does not record as accepted, so no accepted event is
queued again other than to carry a refused count (§The refused count on the
wire). A run with --session projects only the sessions it names. A run’s
events are projected again only when the relay is given that run’s --run.
delivered.idx holds event ids, not payloads, so an outstanding event whose
session log or run input is gone is not sent again: the relay exits
successfully, status and doctor do not count the event, and it stays in the
saved spool, undelivered. The batch counts start again from the new spool; the
delivered event count, which delivered.idx holds, does not.
Before sending, the relay durably claims the attempt and its next deadline.
The first retry waits 60 seconds, which exceeds the HTTP client’s 30-second
exchange budget. The delay doubles to a ceiling of 3,600 seconds, so a lasting
outage receives at most one attempt per hour per batch. Ten attempts bound
automatic work while allowing recovery from transient outages. The normal
schedule reaches its tenth attempt after 14,580 seconds (4 hours 3 minutes).
A failed tenth attempt becomes dead. If the process stops after claiming an
attempt, that batch becomes eligible at its persisted deadline; after the final
claim, the next due invocation marks it dead with its retained error. A claim
whose outcome was never written explicitly records that acceptance is unknown.
Any 2xx status marks a batch delivered; any other status, or no answer, is
a failure. The Content Telemetry standard (schema/) defines no response
body, so a body is optional, and the hub’s onward delivery applies the same
rule (onward delivery). When the body is JSON with an
unsigned events_created, the relay reports that count of newly recorded
events. Otherwise the count is unknown, never zero, and the run’s report
states “new at the receiver: unknown” if any batch in it was accepted without
a count. A zero count is valid for a redelivery. Acceptance of a later batch
does not acknowledge earlier queued or dead batches. Later batches may arrive before
earlier ones, including within the same session; receivers deduplicate event
ids and retain the highest refused count. A crash after acceptance but before
local acknowledgement may send the same event identifiers again. Undelivered
payloads, dead batches included, remain on disk. Where a directory selection
applies to the home or the batch, current directory consent, reporting approvals and source
policy are checked again when the batch is due. The relay sends the cleared subset and records
acceptance for that delivery, leaving withheld event ids out of delivered.idx.
Private batch metadata records delivered_subset: true when the accepted
attempt omitted events from the retained batch, and false when it included
all of them. The field is absent until the batch is accepted, and may be
absent on an older accepted batch, whose subset is then unknown.
Those ids can be projected again if clearance returns. If no events remain
cleared, the batch stays queued with a separate hold reason and consumes no
HTTP attempt; the last delivery error is preserved. Repeated unchanged holds
do not rewrite metadata. A held batch is rechecked on later due invocations,
and the first due invocation after clearance returns (a new opt-in, or a
re-approval on a managed edge) sends it. A batch is rechecked only when due, so
one in retry backoff from an earlier failed attempt is neither held nor sent
before its deadline, whatever its clearance
(crates/commonmeasure-cli/tests/hosted_service/reporting.rs).
Queued, held and dead batches are undelivered. A held reporting obligation has
no automatic grace period. commonmeasure status, commonmeasure doctor and
the console show queued, dead and delivered batch counts, oldest queued age,
next attempt and last error. The existing delivered and pending event counts
remain separate; pending includes dead batches. Unreadable delivery state is
reported as unavailable, with unknown counts. The relay’s closing summary names
queued batches’ next due time; when every queued batch is waiting for its
deadline, it says none are due instead of reporting a zero delivery. Relay,
status and doctor name the attempt limit for dead batches and explain that
commonmeasure relay requeue starts another schedule. Status JSON includes
max_attempts and dead_remedy; doctor JSON carries the same findings as its
text report.
next_attempt_at reports only a persisted deadline for an unheld queued batch;
it remains null when none exists. Status and doctor distinguish due batches
from policy holds, and report the hold reason separately from delivery errors.
The status report exposes held and due_now batch counts and hold_reason
separately. A batch whose session log does not read at the directory recheck
is held the same way and counted in held; its reason states that the log
does not read, and status, doctor and the console print it under the same
“policy hold” label.
A relay forecast includes only batches currently due and cleared for delivery:
the batches earlier runs spooled that are due, and the events this run would
project, turn boundaries included. commonmeasure relay --dry-run leads with
the events and batches that would be delivered and their count per event type
(content_retrieved, content_grounded, turn_started, turn_completed),
and a real run prints the same lines for what it delivered. The “newly
spooled” count that follows covers this run’s projection only, so it is lower
than the delivered count whenever earlier batches are due
(crates/commonmeasure-relay/tests/relay.rs
a_dry_run_forecasts_per_type_exactly_what_the_real_run_then_sends).
A forecast is not the run. It makes no network call and writes nothing, so it
syncs neither the managed policy nor the reporting approvals, and it projects
against the policy and the approvals already on disk. A real run syncs both
before it projects, and the clearances and internal prefixes it then reads
can differ from the ones forecast, on a managed edge in particular. The
forecast ends with two lines saying what it was taken against — the applied
managed revision and whether it is stale, the local policy file, or the draft
passed with --policy — and that a real run refreshes both first. Over an
unchanged home the counts match.
commonmeasure relay requeue starts another schedule for all dead batches;
commonmeasure relay requeue --batch 3 selects spool index 3. This resets the
schedule’s attempt count and makes the batch due immediately, retaining its
last attempt and error until the next claim. Requeue sends nothing; run
commonmeasure relay to deliver due batches. Queued and delivered batches are
not requeued. CLI and service-mode invocations share the same persisted cadence
and an exclusive spool lock. Service mode checks on its existing interval;
the standalone command does not start a background worker. On a local edge
the session-end hook starts one relay run in the background (§Relay at session
end). A run takes the spool lock before it asks the hub for a standing,
refreshes the reporting approvals or writes to the relay directory, so a second run
started at the same moment exits before doing any of those. The command syncs
managed policy before the relay takes the lock, so on a managed edge the
losing run can still fetch the policy envelope and rewrite policy.json
before it exits.
Processes of two releases may share an Edge home while an update settles;
each checks the files it reads (ARCHITECTURE.md §What runs where).
Withheld sessions
Section titled “Withheld sessions”A session is withheld when it witnessed crossings and the relay cleared none
of them to leave: no scope cleared its directory and no crossing was admitted
under the reporting consent. A session skipped because its log does not read
is not withheld and is not counted with them; it is named with the log and the
line (§Delivery state). For each withheld session the relay records the
directories its witnessed crossings ran in (payload.cwd), each resolved once
against the policy the run read, and the rule that kept that directory’s
crossings here. The rule is one branch of the scope resolution
(crates/commonmeasure-harness/src/policy.rs EgressWithheld), serialised as
cause:
cause |
Rule | Further members |
|---|---|---|
no_directory |
the crossings recorded no working directory | |
no_scope |
no scope’s match selects the directory |
|
scope_not_cleared |
the scope that selects it does not set allow_telemetry_egress: true, whether it sets it to false or leaves it out |
scope, the scope’s match |
directory_not_selected |
directory selection is on and no selected directory holds it | |
directory_local_only |
the selected directory holding it was selected local-only | root |
directory_not_approved |
on a managed edge, the hub’s reporting approvals do not include the selected directory holding it | root |
failed_closed |
resolving the scope failed closed | reason |
Other causes of an empty report, such as an approvals renewal the edge did not
record (EDG-41), are to be added as further cause values with their own
line; a reader treats a value it does not know as a reason it cannot name.
commonmeasure relay prints, under “N withheld: no crossing cleared to
leave”, one line per rule with the number of sessions it withheld and the
directories they ran in, three by name at most:
2 withheld: no crossing cleared to leave 1 session: no scope in /home/op/.commonmeasure/policy.json selects /home/op 1 session: the scope "/work/client-a" in /home/op/.commonmeasure/policy.json selects /work/client-a and does not set allow_telemetry_egress: truerelay --json prints the run’s session accounting: dry_run, receiver,
policy, sessions_read, sessions_projected, sessions_withheld,
withheld_sessions (each session, path, withheld_at and
directories, each directory, crossings, cause and its members, and
reason, the rule in words), withheld_summary (the lines above),
sessions_withheld_access_context, crossings_withheld_access_context,
sessions_unreadable, unreadable_sessions (each session, path,
cause, batches_held), runs_projected, events_enqueued,
events_delivered, batches_delivered, batches_queued and batches_dead.
A run that skipped a log prints the document and exits non-zero; a delivery
failure prints no document.
A run that is not a forecast keeps the sessions it withheld in
relay/withheld-sessions.json, replaced as relay/skipped-sessions.json is (a
run scoped with --session replaces only the entries of the sessions it
read). commonmeasure doctor prints each rule’s line from it under Relay as a
note (“the relay run at <time> withheld 1 session: …”), and doctor --json
carries the entries as sessions.withheld beside sessions.unreadable. The
egress block of /api/status carries withheld_sessions, withheld_text and,
where the file does not read, withheld_error. commonmeasure session states
the same rules for one session against the policy on disk now
(session evidence §Reading it).
Receiver and key
Section titled “Receiver and key”The API key in relay.json belongs to the receiver in relay.json; on an
enrolled edge it is the hub’s ingest key. The relay sends it as X-API-Key
only to that receiver’s origin, compared as the instance issuer is compared
(§Instance reference). A delivery to another receiver named with --receiver
carries the key given with --api-key, or no X-API-Key header. The enrolled
key’s standing is asked of the enrolled hub under the key whose receiver has
the hub’s origin, so a key given with --api-key for another receiver is not
sent to the hub. The other requests an enrolled edge makes to its hub under
the stored key follow the same rule: the reporting approval refresh, the
directory proof refresh outside a relay run and disconnect send the
relay.json key only where the receiver in relay.json has the origin of the
hub in enrolment.json (EnrolmentRecord::hub_ingest_key), and otherwise
report that no ingest key is held for the hub and send nothing. A dry run
sends and prints no key.
Relay at session end
Section titled “Relay at session end”A local edge relays when a session ends, as well as when commonmeasure relay is run. The session-end hook, after recording session_ended,
starts commonmeasure relay as a separate process when relay.json names a
receiver and the marker file relay/manual is absent, and starts nothing
otherwise. The run is a whole relay run: every session log and every due
spooled batch, because the MCP server a host starts writes its crossings
under its own session identifier and the hook’s session holds only part of
the work. Clearance, reporting approvals, backoff and the spool lock apply as to any run.
The hook does not wait for the run and its exit code does not depend on it.
The child takes no standard input or output from the hook and runs in its
own process group, so the host neither waits for its output nor ends it with
the hook. A run that finds the spool lock held by another relay exits at
once, before it has asked the hub for a standing or written to the relay
directory (on a managed edge the command’s policy sync has already run); what
it would have projected stays in the session logs, and the batches already
spooled stay there, for the next run. The child’s own output is discarded:
its outcome is in relay/receipts.json and the spool, which status and
doctor report, doctor with the last delivery time and its age.
On Windows the child is started with DETACHED_PROCESS,
CREATE_NEW_PROCESS_GROUP and CREATE_BREAKAWAY_FROM_JOB, the last so a
host that runs its hooks in a job object configured to kill its processes
does not take the relay with it. A job that forbids breakaway fails the
spawn, so the child is then started without that flag and shares the job’s
fate. This path is planned: it has not been run on Windows, and no test
here exercises it.
Only a host that sends a session-end event to the hook relays this way.
install claude registers the SessionEnd hook and the plugin’s
hooks.json registers the same events; the other hosts’ installs register
none (docs/contracts/host-integration.md §2), so an edge used only through
them relays when commonmeasure relay is run, or on the background relay’s
interval where one runs (§Relay on an interval). A session that ends without
the event, a crash for example, relays at the next session end. Removing
relay.json stops it with every other relay, and writing relay/manual
stops it alone, which also leaves a licence’s reporting demand unmet
(session evidence §Source declarations). The hosted service honours the same marker: its
interval relay does not run while the marker is present, and each skipped
interval is a journal line naming the marker
(crates/commonmeasure-cli/tests/hosted_service.rs
the_service_skips_its_interval_relay_while_the_manual_marker_is_present).
Tested in
crates/commonmeasure-cli/tests/hook_e2e.rs:
a_session_end_relays_in_the_background_without_the_hook_waiting against a
loopback receiver that records the bytes posted, and
a_session_end_starts_nothing_without_a_receiver_or_under_the_manual_marker
for the two cases that start nothing. Both establish the process behaviour
on this platform, not delivery to a hub.
Relay on an interval
Section titled “Relay on an interval”A host that sends no session-end event (Claude Desktop, Codex, Cursor, the
Copilot CLI, VS Code, Pi) relays without a person only through a process
that relays on an interval: the hosted service
(host-integration.md §1), or the background relay,
commonmeasure relay --every <seconds>, for a local edge, with an interval
from 1 second to a day (86,400 seconds); a longer one is refused, because a
running loop admits a reporting demand whose report then waits that long.
The background relay runs a relay at start and then every interval until
SIGTERM or SIGINT, which end it once the run in progress has finished. Each run is the
one a session end starts: every session log and every due spooled batch, to
the receiver in relay.json only (--every takes none of --receiver,
--api-key, --run, --session, --dry-run or --policy), under the same
clearance, reporting approvals, backoff and spool lock, with
the managed policy synced first as commonmeasure relay syncs it. It sends
nothing a session-end run over the same home would not send. It does not
start without a receiver in relay.json, with a relay.json that does not
load, or while another background relay holds the home; it says why and
exits 0. It skips a run while the receiver is gone or relay/manual is
present, each time with a journal line naming why.
For as long as it runs it holds relay-loop.lock in the home, created
readable by its owner only, and writes its pid and interval into it. A
second background relay on the same home keeps trying for a second, so a
probe’s momentary hold does not refuse it, and is then refused, naming the
lock and, while that process is alive, the holder’s pid and interval. The
lock is apart from the spool’s relay/spool/delivery.lock, which it takes
only during a run, so a session-end run or a typed commonmeasure relay
between its runs works as before; one that meets a run in progress loses the
spool lock and leaves its work to the next run. The licence ruling, status
and doctor read the lock (session evidence §Source
declarations). commonmeasure service install relay runs it at login on macOS as the LaunchAgent
ai.commonmeasure.relay, logging to logs/relay.log in the home. Its
KeepAlive is SuccessfulExit false: launchd restarts the loop after an
unsuccessful exit (an exit status other than 0, or a signal that kills it,
such as a crash), and its ThrottleInterval of 300 seconds keeps it from
starting more than once in five minutes, so a loop that crashes at every
start runs a relay no more often than one on the default interval. Launchd
counts from the last start, so a loop that ran for longer before it crashed
starts again at once. It leaves the loop stopped after exit 0, which is
SIGTERM or a refusal at start. A refused loop therefore stays stopped until
service install relay runs again or the next login, including one refused
for a cause that clears by itself: a home on a volume mounted after login
has no relay.json yet, so the loop refuses for want of a receiver and
stays stopped after the volume mounts. doctor names the state and the log.
On other platforms service refuses and names the command to run under the
platform’s own service manager.
The edge sees no end to a Claude Desktop session. Each run relays such a
session as far as its log reads, as the hosted service’s interval relay
does. Event ids are derived from the records they project from
(§Delivery state), and an event already in relay/delivered.idx or in a
spooled batch is not projected again, so a crossing written after one run
leaves on the next and nothing leaves twice. A last line being written at that
moment is waited for, and one a writer that died left cut short is marked
before the log is read (session evidence §Reading a log).
A session whose log does not read is skipped for that run and named, with the
line (relay/skipped-sessions.json), and the next run reads it again.
Tested against loopback receivers: crates/commonmeasure-cli/tests/mediated_e2e.rs
reporting_demand::a_claude_desktop_session_is_admitted_and_delivered_only_while_the_background_relay_runs
(a Claude Desktop session refused without the loop, admitted with it,
delivered on the loop’s next tick while the session is still being written,
no event posted twice, SIGTERM releasing the lock), and
crates/commonmeasure-cli/tests/relay_e2e.rs
the_background_relay_runs_once_per_home_stops_on_sigterm_and_is_reported_in_each_state
and a_session_end_and_the_background_relay_at_once_deliver_each_event_once.
The admitted crossing in the first is to a loopback origin, which the
private-address floor never projects, so the event delivered is that
record moved to a public host and appended to the same log. The LaunchAgent
is tested against a recorded launchctl, not a real launchd.
Grounding from host observations
Section titled “Grounding from host observations”The relay emits one content_grounded per eligible context-entry observation,
with its representation hash and generation as turn_id. It derives clearance,
source identity, licence and the private-address/internal exclusions from the
referenced acquisition. Event IDs use the observation’s original log position;
the owning engagement remains that of the acquisition. Missing token counts
remain absent. Retrieval of unused content emits only content_retrieved.
Several representations of one acquisition in a generation produce separate
grounding events. Their hashes describe the representations; the event count
measures representation occurrences. Receivers measuring distinct sources per
generation count distinct (turn_id, content_url) pairs within the session,
not grounding events. Event IDs remain the deduplication key for delivery retries.
For a crossing that handed a file over (session evidence delivered_file), an
observation grounds the file only where its representation_hash equals the
crossing’s content_hash, the SHA-256 of the bytes handed over. Any other
observation of that acquisition projects no grounding and is not counted in
commonmeasure-output-associations. The fetch result gives the host that
hash, so equality ties the host’s claim to the delivered bytes; it does not
show that the file entered the model request, and it says nothing about which
pages the model read. The grounding remains the host’s claim. A page’s
observation grounds under the host’s hash, whatever it is. On Claude Code
the edge makes that claim for a file only where the transcript shows the
file’s bytes in the request
(host integration §Context entry and citation on Claude Code).
Citation from host observations
Section titled “Citation from host observations”An output observation
(session evidence §Output-association observations)
projects one content_cited per distinct source among its eligible
references. A reference is eligible where it names an admitted acquisition
recorded before the output, cleared and passing the exclusions of §Selected
coverage, and a context entry of that acquisition in the output’s
generation grounds it (§Grounding from host observations). An unknown
handle, and a known one the generation’s observed context did not carry,
stays an unresolved reference in the source record. The output must be
acknowledged: its completion boundary is in the log. Repeating a
reference, or naming two acquisitions of one URL, is one citation, since
Content Telemetry counts one occurrence per association between a source
and an output (section 4.3).
| Member | Value |
|---|---|
id |
derived from the session, the output observation’s log position and the content_url |
timestamp |
the output observation’s |
content_url, license_ref |
the acquisition’s, as on its retrieval |
turn_id |
the output’s generation, as on an observed grounding |
output_id |
a UUID derived from the session and the output observation’s log position; the host’s own output identifier stays in the source record |
data.citation_type, data.position |
unclassified: the host marked an explicit reference or quotation and did not classify it |
data.content_hash |
the context entry’s representation hash, where the generation’s entries for that source carry one hash; absent where they carry several |
Clearance and the owning engagement are the acquisition’s, as for a grounding from a host observation; a reporting consent that clears the crossing clears its citations too. The output’s text, its hash and the quotation never enter telemetry: the record holds none of them. A citation says the answer named the source by URL or handle. It is not a measure of semantic support, and no citation is inferred from meaning.
Directory reporting consent
Section titled “Directory reporting consent”After directory enrolment, the relay requires current local root permission and, on a managed edge, its current signed reporting approval, alongside source-policy clearance and the privacy floor. The check applies to new projection and queued batches immediately before delivery. Opt-in includes existing eligible witnessed evidence; opt-out preserves the original record and does not recall deliveries. No field is added to Content Telemetry. See directory enrolment.
The agent id on the wire
Section titled “The agent id on the wire”The relay initially takes agent_id from the session’s first edge_identity
record, or uses commonmeasure when there is none. It pins that value per
wire session in relay/session-agents.json before the first delivery attempt,
so every later batch and retry keeps the same id even when the session resumes
or compacts after enrolment. Retained spool batches supply the first id for a
session with no pin: one queued by an earlier run and not yet delivered, or
one whose pin file was lost; a relay run with nothing to deliver keeps all
pins. The pin is private relay state and adds no wire field.
The refused count on the wire
Section titled “The refused count on the wire”The relay never projects a refused crossing: no URL, no reason, no hash of
the bytes it withheld. What it projects, on every batch of a session it
delivers, is refused: the number of crossing_refused records in that
session cleared for egress as a crossing would be (§Selected coverage), as
an integer at the top of the batch document and nothing else about them.
The count exists so an organisation’s owner can see that policy was
enforced across its edges, and it is a count of enforcement, not of
sources: a refusal to a private address or a named internal prefix is
counted like any other, because nothing about the address leaves. A
context_fetch call for a part past the end of a page’s text, and a call
for a part of a PDF, are recorded as crossing_refused and counted here
too: the request was made and nothing was delivered.
The count is the session’s running total at the time the batch is
projected, never a per-batch difference. Every batch of one session
carries the same value, a later batch for the same session carries the
later total, and a receiver keeps the larger value it has seen rather than
summing, so a redelivered batch changes nothing. When a session’s total has
moved since a receiver last accepted a batch for it and there is no new
event to carry it, the relay sends the last event of the session’s
projection again, under its own id, one the receiver already holds or that is
already spooled for it, on a batch carrying the new total: the receiver
takes the larger count, so a refusal after the
session’s last admitted crossing still crosses. The relay keeps, per
session, the highest count a receiver has accepted, whatever the receiver
(relay/refused-delivered.json), and treats a count in a batch still in the
spool as sent. Its summary states the totals it queued for the wire this run and nothing
more. A session that was refused and admitted nothing produces no batch and
its count does not cross; a session no scope cleared, whose events leave
under reporting consent alone (§Selected coverage), carries no refused
member, since the count is taken over cleared scopes and there is none; a batch without the field comes from an edge that
does not report it, which is not the same as a count of zero.
conformance/session-refused.json is the vector.
Verification
Section titled “Verification”crates/commonmeasure-relay/tests/relay.rs exercises log reading, scope
resolution, projection, durable spooling and HTTP delivery to a loopback
receiver. It checks minimal turn disclosure and stable IDs across policy
changes, selected coverage exclusions, and known, zero and unknown ingestion
counts for sessions and runs. These are fixture-tested inputs through the real
relay transport; they do not establish Hub or external receiver acceptance.
The same tests check that the instance reference reaches only the receiver
whose origin is its issuer, on content events only, at each source record’s
revision, that an unregistered session’s batch carries none, and that a batch
spooled for the issuer and delivered to another receiver is posted without
the member; the receivers there record what the relay posts and are not hubs.
The receiver URL comparison is unit-tested in
crates/commonmeasure-harness/src/relay_config.rs.
crates/commonmeasure-relay/tests/conformance.rs validates the corpus against
the pinned schemas, including rejection of missing or invalid turn privacy
levels. A receiver must re-pin the changed corpus and run its own replay gate.
crates/commonmeasure-cli/tests/cited_e2e.rs drives Claude Code’s path
through the binary: a server opted into host observations fetches two
loopback pages, the Stop hook reads a transcript fixture in Claude Code’s
recorded shape whose answer cites one, and the relay sends one
content_cited, for that page, with no page or answer text. The receiver
there records what the relay posts; Common Measure Hub’s acceptance of
content_cited is not established: the hub refuses the kind today
(HUB-101).
crates/commonmeasure-relay/tests/paced_retrieval.rs fetches a page twice
inside a loopback origin’s Crawl-delay through the real context_fetch,
pacing store and transport, and projects the waited crossing: its retrieval
carries the delay kept and the timestamp the crossing was recorded with,
which is after the page answered.