Skip to content
Edge documentation

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 Telemetry event_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 in crates/commonmeasure-cli/tests/instance_e2e.rs deliver 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.

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_ref where the crossing’s licence is declared, 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 evidence delivered_file) also carries data.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.scope session, data.content_hash and the ingestion measure (§Ingestion measure). Where the crossing records the part of the extracted text its fetch result carried (session evidence delivered), data.content_hash is that part’s hash, delivered.hash, the same text tokens_ingested counts; for a page delivered whole it equals the crossing’s content_hash. A crossing that records a part without a valid delivered.hash carries no data.content_hash. A crossing that records no part (search results, and logs from before parts were recorded) carries its content_hash, where that is a valid sha256: 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 never retrieved_hash, the hash of the body as served. A crossing whose context entry the host observes is grounded by the observation instead, with data.scope turn and 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_id and data.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.

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.

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.

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.

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).

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.

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).

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: true

relay --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).

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.

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.

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.

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).

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.

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 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 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.

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.