Session evidence contract
This contract is licensed under CC-BY-4.0 (docs/contracts/LICENSE).
This contract defines what a harness session records. The batch runner’s
equivalent is docs/contracts/run-output.md. The two are different shapes
because a run is one bounded comparison and a session is an open-ended
period of somebody’s work. Terms like crossing,
engagement, scope and gap are defined in docs/GLOSSARY.md.
$COMMONMEASURE_HOME/sessions/<session-id>.ndjson, or
~/.commonmeasure/sessions/<session-id>.ndjson.
A record appended while a registration is retained for the session carries
one optional instance member: issuer, the registration service
identifier; id, its instance identifier; and revision, the immutable
authority binding held when the record was written (§Instance registration).
The reference resolves to the retained binding defined by the
instance registration contract.
It leaves session_id, host and the edge identity unchanged. A shared
session may carry records from different instances. Absence means no recorded
registration reference; it does not invalidate earlier records or establish
authority. The reference proves neither compliance nor fulfilment. It is
private to the operator and the registration service that issued it. The
relay sends it to one receiver only: the operator’s own hub, where that hub is
the reference’s issuer, as an event-level instance member on content events
(telemetry projection §Instance reference). The hub removes the
member before onward delivery, so it reaches no publisher, and it adds no
Content Telemetry or SPUR field to what a publisher receives. Emission is
built; a reader that verifies the reference against the retained binding is
planned.
A hook takes the session identifier from the host’s payload. The MCP server takes the first of:
--session, which the host’s registration passes;AGENT_SESSION_IDin the server’s environment, which Goose sets for every server it starts, so its records share one log per Goose session;- an identifier the server mints,
local-<milliseconds>-<process id>.
The identifier names a file, so it must be a plain name: ASCII letters,
digits, ., _ and -, one to 128 characters, not starting with .. Any
other identifier is refused, never cleaned: a hook given one records nothing
and exits zero, and the MCP server given one fails to start, naming where
the identifier came from (crates/commonmeasure-harness/src/session.rs
safe_session).
A hosted session (docs/contracts/host-integration.md
§1, the hosted path) is one protocol session over HTTP. The edge mints its
identifier at initialize, hosted-<milliseconds>-<128 random bits in hex>,
and the same value is the Mcp-Session-Id header on every request and the
session_id on every record, so a request in a web server’s log and a
record in the file are joined without a lookup table. The file is opened at
the session’s first request that is not initialize,
notifications/initialized, tools/list or ping; a session that is asked
for nothing else leaves no file. On the enrolled edge a hosted session runs
on, the file’s first records are hosted_scope (where the service declares
a directory, §Hosted scope), policy_sync (trigger server_start, where the
edge is managed) and edge_identity, written at open, before any crossing
(crates/commonmeasure-cli/src/mcp_session.rs).
One file per session, opened for append. A session spans many short-lived hook
processes, so seq resumes from what is already in the file. Under concurrent
hooks two records can share a seq; a reader orders by position and treats
seq as a hint. Every append holds the log’s exclusive advisory lock
(flock(2)) from reading the file’s last byte to the end of its one fsynced
write, so appends never interleave, and a record cut short by a writer that
died is marked by the next append (§Reading a log). A write failure is
materialised as an evidence_gap record by the next successful write on
the same log instance (docs/FAIL-POLICY.md §2).
That scope matters for a session in a way it does not for a run. The owed gap is held in memory by the log object that failed the write, so it can only be written by a later append through that same object. A hook process is one short-lived log per invocation, commonly one append, so a hook whose append fails leaves no gap record and no later process knows it happened. Session-log completeness is therefore per-process: the log records every window that a process which survived to write again could describe, and records nothing about a process that did not, beyond the torn line such a process can leave (§Reading a log).
Reading a log
Section titled “Reading a log”Every reader of a session log applies one rule
(crates/commonmeasure-runtime/src/evidence/scan.rs): the strict read that
commonmeasure session, the console and the observation index use, the relay’s
read for delivery, and the reporting ruling’s check that the relay would read
the log. A ruling therefore cannot admit a crossing into a log the relay then
skips.
- A line that parses as JSON is a record. A blank line is skipped.
- A line that does not parse is skipped only where the next line is an
evidence_gaprecord whosepayload.torn_line_bytesequals that line’s length in bytes, without its newline. The gap record itself is a record. - Every other line that does not parse is a hard error for every reader, naming the line: one in the middle of the log, one at the end with its newline, one followed by a blank line, by another line that does not parse or by a gap record of another length. The relay skips such a log whole (§Instance registration) and the ruling refuses a reporting demand into it. Nothing repairs it.
- A final line without its newline is either another process’s append in
progress or a record cut short by a writer that died. A reader tells them
apart by taking the lock: a writer holds it until its write has returned,
and the kernel drops a lock when the process holding it exits, however it
exits. Under the lock the line is read again. A line an append was writing
is now whole. A line still without its newline was cut short, and:
- the next append, from any process, terminates it with a newline and
writes the
evidence_gaprecord that marks it before its own record (reason: "write_failed",fromthe file’s last modification, which was the torn write,tothe repair, andtorn_line_bytes); - the relay, when it reads for delivery, marks it the same way first, so a session whose last writer died mid-append is delivered whether or not anything appends to it again;
- a forecast relay run (
--dry-run), the reporting ruling andcommonmeasure sessionread the log as it will read once marked and write nothing: the line is reported as a tear not yet marked, and a line that parses (a whole record missing only its newline) is kept; - the strict read (
SessionLog::read) fails, naming the line.
- the next append, from any process, terminates it with a newline and
writes the
- A reader or writer waits up to five seconds for the lock. A process that holds it longer (stopped under a debugger, say) makes the append fail, which the log records as an owed gap, and a read fail naming the lock.
- A marked tear is shown by
commonmeasure session(tornlines, andtearsin--json). The console, which indexes every line it can and names the rest, counts the torn line among the session’s unreadable lines and its gap record among its evidence gaps. What a torn line that does not parse was recording is lost; the gap record says so and when.
The lock is advisory and is taken by every writer of this release and later.
A writer of 0.4.8 or earlier takes none. An append of this release that meets
such a writer’s record in progress relies on the kernel keeping one write
to a file opened O_APPEND whole, as those releases did: its newline and gap
record land after that record, which leaves a blank line and a gap record
whose length matches no torn line, and no record is lost. A repair that is
itself cut short (the disk filling during it) leaves two lines in a row that
do not parse, and the log stays unreadable.
Events
Section titled “Events”Each event is defined by what the host supplies. The Claude Code hook that
supplies it today is given as the example; what every host must provide is
in docs/contracts/host-integration.md §2.
| Event | The host supplies | Meaning |
|---|---|---|
crossing_observed |
a hook after each tool call carrying the tool’s input and response (Claude Code: PostToolUse) |
the agent read this, and a hook saw it afterwards |
crossing_reconstructed |
a transcript that records tool results, read by commonmeasure import |
read back from a host transcript, for work done before Common Measure was installed |
crossing_mediated |
the agent’s fetch or search routed through the MCP server | Common Measure carried this, under policy |
crossing_refused |
the same | policy refused this before it could enter the agent’s context |
processor_invoked |
the same | one processor invocation (docs/contracts/processor.md), recorded beside the crossing it judged |
display_rendered |
the agent’s context_show call through the MCP server |
a reply was displayed against a signed release this session delivered (host integration §Displaying a reply against a release): tool, processor_record (the seq of the integrity-checker record just before it), release (content_hash and acquisition_ids), display_mode, outcomes (one per item, in order), counts (names, vector, status), session_id, host and cwd. No sentence, passage text, reason or reply-supplied identifier. Never a crossing and never projected |
passes_refilled |
nothing; commonmeasure passes refill <host> writes it |
one refill of anonymous passes from the Anchor the host’s terms name, under the product decision “Anonymous access profile” (source policy §Terms): anchor (its origin), origin_info (the source hosts the passes may be spent at), requested, obtained, held (the passes the wallet holds for that host afterwards), discarded (the passes, for any host, dropped because they were issued under a key other than the one the Anchor’s directory lists: after a rotation they verify nowhere that follows it, and a source’s challenge naming the new key is never answered with one), key_id (the lowercase hex SHA-256 of the Anchor’s token key, which each pass carries), stopped (null; or the Anchor’s refusal as it stated it, status, retry_after as sent and stated, its body as JSON where it is JSON and otherwise text, cut at 2 KiB, with any echo of the credential replaced by [credential]; or failed with the reason), session_id and timestamp. A pass says only that the Anchor found the agent meets a policy the Anchor publishes; it identifies neither the operator, the reader nor the session. The record carries no pass, no blinded request and no credential. The passes are kept in <home>/passes/<anchor>.json, owner-only, beside the Anchor’s key; a stolen wallet file yields the passes in it, each spendable once at the hosts it names while the Anchor’s key stands, and neither an identity nor the credential. Never a crossing, never cleared for egress and never projected |
prompt_sources |
a hook at prompt submission carrying the prompt text (Claude Code: UserPromptSubmit) |
which URLs the prompt named, as hashes, and the statements pasted material carried; no prompt text |
manifest_resolved |
nothing; the MCP server writes it after a mediated fetch | what Content Telemetry discovery established for the host: the URLs probed, the status, the cache decision, and the manifest’s facts or the rejection; never a crossing |
turn_started |
a hook at prompt submission (Claude Code: UserPromptSubmit) |
a turn boundary, carrying the host’s turn identifier, a declared privacy level and the policy identity (§Turn boundaries) |
turn_completed |
a hook at the end of a turn (Claude Code: Stop) |
a turn boundary, the same |
context_snapshot |
a transcript carrying the provider’s usage counters and the host’s own records of what it assembled, read at the end-of-turn hook (Claude Code: Stop) |
a context-budget observation at the boundary, with its basis named, and the inventory of the window by category (§Context snapshots) |
nudge_issued |
a hook at session start whose stdout the host adds to context (Claude Code: SessionStart) |
the standing mediation nudge was emitted for the host to add to the session’s context |
edge_identity |
a hook at session start (Claude Code: SessionStart), and the MCP server’s start, on an enrolled edge |
the identity this edge runs under: the hub by its origin alone (scheme, host and port; null where the stored hub URL has none, as for one that does not parse, with hub_refused saying why nothing is sent to it), the key id the hub assigned at enrolment, and the key’s standing (enrolled; revoked with when and by which side; or, where the stored hub URL is one nothing is sent to, cleartext_hub for a cleartext URL or unusable_hub_url for one carrying credentials, a query or a fragment, with hub_refused saying what the edge refuses and the remedy), and whether the hub’s key directory lists the key, as the edge last learnt it: listed_until, the time the hub stops serving the key’s directory proof (its expiry less the 7,200-second margin), or unlisted with the reason. A reason the hub stated reads hub: <text>; the edge’s own conclusion from a refused directory proof upload reads this edge concluded the key is unlisted when the hub refused its directory proof (<status>): <detail> (enrolment). The edge keeps what it last learnt in <home>/directory-listing.json, bound to the key id, and never in enrolment.json, whose shape stays the one every released binary reads. A signed request under a key the directory does not list verifies nowhere. Absent on an edge that is not enrolled. A record can also hold the stored hub URL whole, and 0.4.1’s connect stored hub URLs with credentials in them; every reader reduces the field to its origin and the log is not rewritten. 0.4.1 built a policy_sync record’s policy_url from that stored URL where the hub gave a policy path and no policy URL, so the console’s session records API (GET /api/sessions/<id>) serves that field by its origin too, and the log keeps it as written |
credentials_loaded |
nothing; the MCP server writes it before the first record a tool call or host observation leaves | the operator credentials file was loaded at server start: its path, digest and variable names, never a value |
policy_sync |
a hook at session start (Claude Code: SessionStart), or the MCP server’s start for a session no hook refreshed |
on a managed edge, what refreshing the policy from the hub did for this session: the outcome, the revision in force and whether its envelope has expired (§Policy synchronisation). Absent on a local edge |
host_process |
a hook at session start (Claude Code: SessionStart), and the MCP server’s start |
the host process this path runs under, so the hook log and the MCP log of one host session can be joined (§Host process) |
session_ended |
a hook at session end (Claude Code: SessionEnd) |
the host reported the session ending, with the reason word the host gave (§Host process) |
hosted_scope |
nothing; the hosted service writes it at a session’s open, where hosted-service.json declares session_directory |
the directory the session’s scope is resolved against and its crossings record as cwd, and that its basis is the service’s configuration, not a directory the client reported (§Hosted scope) |
client_identified |
the MCP client’s initialize request; the MCP server writes it before the first record a tool call or host observation leaves |
how the program on the other end of the stdio pipe named itself: clientInfo.name and clientInfo.version, with the protocol version it asked for and the one the server answered with (§Client identity) |
instance_refused |
nothing; the MCP server writes it | a mediated fetch or search was stopped before anything crossed because the session’s registered instance could not be shown to hold authority: refused where the authority is known to have ended, unavailable where a required check could not be made (§Instance registration); never a crossing |
allowance_gap |
nothing; the MCP server writes it | the allowance ledger did not record a settlement or release for a mediated search: the reservation id, the observed charge where a receipt reported one, and the reason; the reservation stays held until the expiry sweep releases it |
evidence_gap |
nothing; the log writes it | a window this log could not record; with torn_line_bytes, the line before it, which a write cut short (§Reading a log) |
A host-policy refusal happens before any bytes move. A processor refusal,
which the injection screen produces under strict, happens after the fetch
and before the text is returned: the bytes existed, their hash is on the
refused crossing, and they never entered context (grounded is false). The two records differ because they are different facts, and both
are crossing_refused because in both cases the mediated path stopped the
text before it reached the model. The tool error the agent sees says which:
refused before the crossing for the first, refused before the content entered the context for the second.
The claim that a refused crossing’s text never entered context extends to
the tool error itself: for a crossing that is refused, in any mode, or
that fails before delivery, what the agent reads holds nothing the source
or any hop of the crossing chose, so the source’s words enter the model’s
context by no route. The record’s refusal, breach, failure and
declarations keep every such value whole, in their own sentences: the
record and the agent get two strings where they differ (text from the
source in a result).
A context_fetch refused before the crossing on the source’s terms or on
the operator’s policy says whose rule it was, in a parenthesis after the
reason: the source's robots.txt where its access rule refused the URL, or
its Crawl-delay refused a redirect hop; the source's terms for any other
term the source set (a licence it names, its payment or licence-server
term, a content signal, a reporting demand); and operator policy in <policy.json> (the operator's policy on a hosted edge) for a host list,
a required licence, the principal’s authority, the allowance, and a private
address that allow_private_hosts or a named internal prefix could admit.
A source’s term is never attributed to the operator’s policy, in any mode:
the mode governs the operator’s policy alone, and the policy refuses nothing
on a term the source set (FAIL-POLICY §6).
Other refusals carry no parenthesis. A sentence that says all there is
names nobody: a back-off, which names the host’s answer and the wait, the
hub’s origin, a redirect this edge does not follow, a private address under
the floor a service-mode edge holds, which no policy setting lifts, and a
store fault with its remedy. A page or licence turn the source’s
Crawl-delay refused at the URL the agent asked for names the delay in the
sentence and ends with the record. A provider the policy refuses in context_search,
the injection screen and a file the screens cannot read in strict name the
rule in the sentence alone.
What the agent was told
Section titled “What the agent was told”told is the tool error the agent read, whole, on every crossing_refused
and on a crossing_mediated that returned the agent an error rather than a
result: a fetch that failed, an answer that was not a page, a file this edge
does not deliver. A refused search result’s record carries the reason the
agent read for that result in told, and the result’s position in
told_position ({"position": 2, "of": 5}, as the result’s refusals
entry gives them), and none of the supplier’s text. Both are absent on a
delivered page or file, where the result is what the agent read, and on
records written before the fields existed.
Neither is projected to Content Telemetry (projection
contract): the relay builds events from named
fields and reads neither, because on an own edge told names the
operator’s own files, so it stays in the operator record. Test:
what_the_agent_was_told_is_not_projected
(crates/commonmeasure-relay/src/project.rs).
The text is this edge’s own by construction
(commonmeasure_runtime::agent_text): built from fixed text, operator
policy values, the agent’s own request URL, positions and record
references, with no way in for a value the source or a hop chose. Recording
it whole therefore discloses nothing, and lets a reader check from the
record alone, without the transcript, that no value the source set reached
the agent: none of url (beyond the URL the agent itself asked for),
host_name, challenge, content_type or any string under declarations
(the robots.txt rule, group and file, the redirects, each licence’s URL and
terms, the statements, the rendered headers) occurs in told. The record’s
own sentences (refusal, breach, failure) name those values whole beside
it, so a reader sees both what the record holds and what the agent was
told. Test: a_refused_crossing_gives_the_agent_no_text_from_the_source
and a_refused_search_result_gives_the_agent_no_text_from_the_supplier
(crates/commonmeasure-harness/src/mcp/tests/refusal_corpus.rs) check
this across every refusal and failure path a loopback origin can reach, in
the mode that refuses it; a_delivered_page_records_nothing_told pins the
absence.
The string is recorded rather than a digest of it: a digest lets a holder of the transcript verify what was said, but not check absence without the transcript, which is the check this field exists for. Its size is that of one tool error.
A crossing whose authority is the enrolled hub’s is refused before any bytes
move, as a host-policy refusal is, and by the runtime rather than by the
operator’s policy: the authority of the enrolment’s hub, of its
identity.origin, or of a managed deployment’s policy_url, compared by host
and by the port a request would connect to. The mediated path signs each hop
with the enrolled key, which the hub’s own routes accept. refusal carries
one fixed sentence and url names the hop that was refused. No policy setting
lifts it. A robots.txt or licence probe to such an authority is not sent,
and the declarations record says why. A robots.txt that redirects to it is
unreachable (§Source declarations).
Crossing
Section titled “Crossing”The mode decides which fields a record can carry, so there is one example per
mode. A single composite example would show a record no path writes: the
identity fields belong to the mediated path, derived_from to the
reconstructed path, and a reader checking a real log against the composite
would be checking it against a shape that cannot exist.
A mediated crossing carries the most, because Common Measure carried it and could have refused it:
{ "session_id": "…", "timestamp": "…", "requested_at": "…", "mode": "mediated", "host": "codex", "client": {"name": "codex-mcp-client", "version": "0.154.0", "title": "Codex"}, "cwd": "/home/operator/code/project", "policy_scope": "code/project", "principal": "research-agent", "authentication_basis": "os_user", "url": "…", "host_name": "…", "identity": {"user_agent": "CommonMeasureBot/<version> (+https://…/bot; mailto:…)", "key_id": "…", "signature_agent": "https://…"}, "content_hash": "sha256:…", "retrieved_hash": "sha256:…", "estimated_tokens": 12, "token_basis": "characters/4", "delivered": {"offset": 0, "chars": 48, "total_chars": 48, "hash": "sha256:…"}, "grounded": true, "licence": {"state": "unknown"}, "policy_identity": "sha256:…"}timestamp on every crossing is when the record was written. On a
mediated fetch that is after the response, and after any licence read or
manifest probe the crossing made once the page answered, so it is never the
time the request was sent.
requested_at is the instant the mediated fetch handed its page request to
the transport, after any Crawl-delay or back-off wait. It is taken where
the request is handed over, not derived from timestamp. It is taken before
the connection, the TLS handshake and the write, so it is present where the
origin received nothing, and it is never later than the request’s arrival at
the origin. A crossing has at most one:
- Where a redirect was followed (
declarations.redirectsis non-empty), it is the instant of the request toredirects[0].requested_url, not tourl, which names the last hop. Later hops’ send instants are not recorded, so the record does not establish pacing across a redirect chain. - A
crossing_refusedrefused at a later redirect hop carries it, because the first hop’s request was handed over. - It is present where the request was handed over and nothing answered, since
the attempt was made (the crossing carries
failure).failuredoes not implyrequested_at: a crossing that stopped before the request also carriesfailureand has none. - It is absent where no page request was handed over: a refusal before the request, a first-hop name that could not be resolved, a turn that could not be kept, a host in back-off, a request that could not be signed, or a call with no time left. It is also absent on search results, observed and reconstructed crossings, and every record written before the field existed.
A reader never presents timestamp, or any field other than requested_at,
as the send time. Where requested_at is absent a reader may still use
timestamp as the record time. The relay does not project it; the retrieval
event’s timestamp is the record’s
(telemetry projection).
A robots.txt, licence or manifest probe is not the page request and does
not set it.
retrieved_hash is SHA-256 over the response body as the origin served it,
and appears on a mediated fetch that received a body and on no other record.
content_hash beside it covers the whole text extracted from that body,
delivered to the agent or withheld from it, which for an HTML page is the
page’s readable text and not its markup (docs/contracts/processor.md §Status, html-text-extractor). An
origin that serves the body under the gzip content coding, the one coding
each request names in Accept-Encoding, has retrieved_hash over the coded
bytes it served and content_hash over the text taken from what they decode
to. The two are equal only where the body was served with no content coding
and delivered as decoded. A body under any other content coding, which the
request did not accept and the failure says so, or one that does not
decode, delivers nothing: the crossing carries the failure that names the
cause and neither hash. Where the two hashes differ,
the processor_invoked record of the extractor written before the crossing
carries retrieved_hash as its input hash and content_hash as its output
hash, and a reader re-derives the second from the bytes the first names by
applying the rules the record’s configuration digest pins
(docs/FAIL-POLICY.md §12). estimated_tokens counts the delivered text: on
a mediated fetch, the part delivered names, or on a refusal the part the
result would have carried.
The relay never projects retrieved_hash. A grounding carries the hash of
the part the result carried, delivered.hash, or content_hash on a
crossing that records no part
(telemetry projection).
delivered is the part of the extracted text the tool result carried, on a
mediated fetch that returned text and on no other record. One
context_fetch result carries at most max_chars characters from offset
(host integration); characters
are Unicode scalar values and a part never splits one.
offset: characters of the extracted text before the part.chars: characters in the part. It is 0 only where the whole text is empty.total_chars: characters in the whole extracted text, the textcontent_hashcovers.hash: SHA-256 over the UTF-8 bytes of the part, in thesha256:form. Where the part is the whole text it equalscontent_hash.
The crossing’s estimated_tokens is the part’s estimate, so the crossings
of a page read in parts add up to the whole text’s estimate, within one token
per part, while the page is unchanged. total_chars
gives the size of the whole text.
Each part is a crossing of its own: a call for a later part requests the
page again, under the same admission, robots.txt, pacing and allowance as
the first, and the edge keeps no body between calls. Where the page changed
between parts, the two crossings carry different content_hash values. A
call whose offset is above 0 and at or past the end of the text is
recorded as crossing_refused, with both hashes and a refusal naming the
offset and total_chars, and no delivered: the page was requested, and
nothing entered context.
delivered records what the edge handed the host. The edge does not see
what the host then does with the result: whether it placed the part in the
model’s context whole, stored it to a file, or let the model read excerpts
of it. No record describes that, and delivered.hash is not evidence of
what the model read.
A mediated fetch of a PDF hands the file over and extracts no text (host integration). Its crossing carries:
{ "mode": "mediated", "url": "https://example.org/paper.pdf", "requested_at": "…", "http_status": 200, "content_type": "application/pdf", "content_hash": "sha256:…", "retrieved_hash": "sha256:…", "delivered_file": {"via": "local_file", "bytes": 482113, "statement": "delivered as a file, not read by the edge; the PII detector and the injection screen did not run over it"}, "breach": "The PII detector did not rule: the body is a PDF, which the edge does not read. The injection screen did not rule: the body is a PDF, which the edge does not read.", "grounded": false, "licence": {"state": "unknown"}}content_typeisapplication/pdffor a PDF, whatever the origin labelled it. On a crossing refused because the body is another file (image/png, an archive) it is the type as served, in lower case, andrefusalnames it. Where that type held bytes that are not UTF-8,content_type_renderedis true andcontent_typeis the rendering described under §Source declarations, lower-cased with the rest of the type, so the byte 0xE9 reads\xe9; otherwise the flag is absent.content_typeis absent on a body delivered as text.content_hashis SHA-256 over exactly the bytes handed over, after any gzip coding is removed;retrieved_hashis over the bytes as served, and the two are equal where the origin served no coding. No extractor invocation is recorded, since no text was extracted.delivered_file.viaislocal_file(saved under the session’s directory, its path in the result) orembedded_resource(a hosted edge’s resource block).bytesis the size handed over.statementis alwaysdelivered as a file, not read by the edge; the PII detector and the injection screen did not run over it.- There is no
delivered, noestimated_tokensand notoken_basis: the edge counted nothing, so the crossing is one of the console’scrossings_without_estimate.groundedis false. The record claims nothing about what the model read, and the relay projects a retrieval, carryingdata.commonmeasure-delivered-file, and no grounding of its own (a host observation may ground it: telemetry projection). - The admit-stage screens (PII, injection) rule on text and do not run
over a file. Each records a
processor_invokedrecord for a PDF the declarations admit, withdecisionabstain(refuseunderstrict, which refuses the crossing), no findings and acapability_unavailablegap. The unknown verdict is a breach (docs/FAIL-POLICY.md§6): a delivered file’sbreachnames both screens after any other breach, so a delivered file never carriesbreach: null.delivered_fileis what marks the crossing as a file. - A refused file (under
strict, or anoffsetormax_charson a PDF, or a file that could not be saved) never carries the screens’ sentences: itscrossing_refusedhas the reason inrefusal, andbreachonly where a host, allowance or declaration breach applies, as for a refused text page. requested_at,declarations,identityandallowanceare recorded as for any fetch, androbots.txt, a licence or aContent-Usagestatement refuses a PDF before any of it is kept.
release appears on a mediated fetch whose body was served as a signed
release (application/x.signed-release+json, a working format that Common
Measure reads and does not define) and read as one, delivered or refused
after reading. It holds media_type; verification, which is verified,
failed or missing as the release-verifier established it (processor);
reason where it is not verified; and sidecar, the release’s sidecar
whole: the signed fields (source, title, revision, URL, licence,
attribution, terms, display mode, text_sha256, passages and markers as
byte spans of the text, release time), the signature, the signer’s public
key and the scheme. The text itself is never recorded: content_hash is
its hash, retrieved_hash the hash of the body served, and delivered
the part handed over. The stdio server that delivered a release keeps its
text and sidecar in process memory for context_show (the last 16, cleared
at exit, never written; host integration
§Releases held for display); that is not part of the source record. A failed or missing state is also a breach:
strict refuses the crossing, and observe and prefer carry it with
the sentence in breach. A body served under that media type that does
not read as a release, a body that starts like a PDF among them, is
refused as unavailable with no content_hash and no release; the
verifier’s record names why, and no file is saved. The release’s strings
delivered beside the text are screened for injection as the text is: a
match is refused by strict and carried by observe and prefer with
the sentence in breach. The result the agent
reads carries the release text as content and, under release, the
verification state, display mode, terms, attribution, content hash and
the passages and markers by id with their text: every passage where the
whole text is delivered, and otherwise the passages that overlap the part,
each whole. estimated_tokens counts that text with the part. The relay
does not project release.
identity is the network identity the request presented, and it appears on
every mediated crossing whose request left the machine and on no other: a
crossing refused before the request presented nothing, and a search result
was fetched by a supplier under its own contract rather than by this
runtime. An enrolled edge carries key_id and signature_agent, the
origin whose key directory a verifier resolves it in
(bot identity); an edge that is not enrolled, or whose
key the hub has revoked, carries unsigned instead with the reason in it.
The two are different facts, not a value and its absence: a publisher can
verify the first and can only take the second on trust. A request under the
anonymous access profile (below) carries user_agent, the product token and
version alone, and either pass or unsigned: pass where it presented an
anonymous pass, with anchor (the Anchor’s origin) and
truncated_token_key_id (the last byte of the SHA-256 of the Anchor’s token
key, RFC 9578 §6.1, which says which of its keys the pass is under), and
unsigned with the reason where it presented none. key_id and
signature_agent are absent under the profile, enrolled edge or not: such a
request is not a signed CommonMeasureBot request
(bot identity §The anonymous access profile).
The pass itself, its nonce and its challenge digest are never recorded. The
pass is presented at the URL that challenged, on one request; a redirect
from there carries none, and identity describes that request.
anonymous appears on every mediated crossing of a fetch made under the
anonymous access profile, refused, failed, challenged or carried: a fetch
of a URL whose host’s operator terms name an Anchor
(source policy §Terms), under the product
decision “Anonymous access profile”. It holds anchor, that Anchor’s
origin. A pass says only that the Anchor found the agent meets a policy the
Anchor publishes; it identifies neither the operator, the reader nor the
session. Such a crossing stays on this record: it is never cleared for
relay egress in any scope or under any consent, its refusal is not counted
in a session’s refused count, and a licence’s reporting demand on such a
host is unmet (telemetry projection). Where the
host answers 401 with a PrivateToken challenge, the request is made once
more with a held pass; where none answers the challenge the crossing is
crossing_refused with the challenge, the status, identity and the gap in
refusal: no pass for the host in the wallet, passes only under a key the
challenge does not name, a challenge from another issuer, one that does not
bind the pass to the host that challenged, or one that differs from the
challenge passes are obtained for ahead of a fetch (one carrying its own
redemption context, for example). No request is sent without the pass in
its place. A source that refuses the pass presented answers with a new
challenge; that crossing is crossing_mediated with the status, challenge
saying the source did not accept the pass, and identity.pass. A redirect
that leaves the hosts under the fetch’s Anchor, or that leads a fetch not
under the profile to such a host, is refused before it is requested.
supplier appears on a mediated search result and on no other record: the
provider name that served it (exa, ozone, internal, skill:<name>),
as policy and plans use it. A fetch has none, because this runtime made the
request; an observed or reconstructed crossing has none, because the host’s
tool was not a supplier. The relay projects the name for suppliers and
withholds it for the operator’s own corpus and for skills
(crates/commonmeasure-relay/src/project.rs).
challenge appears where the origin refused the fetcher rather than the
resource — a challenge marker such as cf-mitigated, a PrivateToken
challenge (RFC 9577), which asks for an anonymous pass and names its issuer,
or a status that refuses the request itself. It is deliberately distinct from refusal,
which is this operator’s policy, and from failure, which is the
transport. All three leave a crossing with no content hash, and a reader has
to be able to tell whose decision it was: the operator’s, the origin’s, or
nobody’s.
An observed crossing names the host tool that made it and nothing about identity or policy: a hook saw it after it happened.
{ "session_id": "…", "timestamp": "…", "mode": "observed", "host": "claude-code", "tool": "WebFetch", "agent_type": "Explore", "agent_id": "…", "turn_id": "…", "cwd": "/home/operator/code/project", "url": "…", "host_name": "…", "content_hash": "sha256:…", "estimated_tokens": 12, "token_basis": "characters/4", "grounded": true, "licence": {"state": "unknown"}}A reconstructed crossing names the transcript it was read from and no working directory:
{ "session_id": "…", "timestamp": "…", "mode": "reconstructed", "host": "claude-code", "tool": "WebFetch", "url": "…", "host_name": "…", "content_hash": "sha256:…", "estimated_tokens": 12, "token_basis": "characters/4", "grounded": true, "licence": {"state": "unknown"}, "derived_from": "~/.claude/projects/…/<session>.jsonl"}refusal and breach appear only when policy produced one, which only the
mediated path can. Otherwise they are absent rather than null, as every
optional field here is: a reader can tell “policy refused nothing” from “no
policy was watching” by which mode the record is in, not by a null.
mode is recorded, never inferred from the code path that wrote the row. The
three are not the same grade of evidence, and nothing merges them:
- mediated — governed before it happened, and could have been refused;
- observed — seen by a hook after it already had;
- reconstructed — read back from a host transcript afterwards, with nothing
watching at the time. The weakest grade, and not called observed because a
transcript is written by the host for its own purposes and can be compacted
or truncated, so a hash taken from one is a claim about the transcript and
not about what entered the model’s context.
derived_fromnames the file.
Embedded credentials on acquisition
Section titled “Embedded credentials on acquisition”Before text transformation, html-text-extractor version 2 verifies supported
C2PA credentials against the unmodified body after removal of content coding.
It reads Annex A.7 inline manifests in UTF-8 HTML and Annex A.8 wrappers in
other UTF-8 text/* bodies. Verification runs before HTML markup or a
recovered text wrapper is removed. Invalid and untrusted recovered wrappers
are also removed; unsupported or incomplete wrappers stay in the text.
The processor_invoked record’s detail.embedded_credential contains:
| Field | Meaning |
|---|---|
state |
absent, invalid, untrusted or unavailable |
credential_ref |
SHA-256 of the embedded manifest store; the declared link for an external manifest; otherwise null |
manifest_label |
The SDK’s active manifest label, or null where no manifest could be read |
method |
annex-a8, annex-a7-script, annex-a7-link or annex-a7; null where the content type or encoding is unsupported |
verifier, verifier_version |
C2PA SDK identity and version |
validation_status |
SDK status codes, URLs and explanations; empty where no SDK reading completed |
trust_list, reason |
The trust configuration and reason for the result |
untrusted means the signature and content binding verify but no signer trust
list is configured. The current verifier cannot issue trusted. invalid
means the wrapper or manifest is malformed, or verification failed, including
when the signed content was altered. unavailable names the missing reader,
encoding support or external-manifest retrieval capability and adds a
capability_unavailable gap. Verification is offline; external manifest
links are recorded without being fetched. absent is limited to supported
text bodies in which no supported credential was found.
A body served as a signed release (application/x.signed-release+json)
does not reach the extractor and gets no embedded-credential reading: the
release-verifier reads it instead, and its record carries the same two
hashes as input and output (processor). Its
signature is kept on the crossing under release.
The same invocation carries retrieved_hash as its input, content_hash as
its output, the transformation’s processor name, version and configuration
digest, and detail.credential_wrapper_removed for Annex A.8 removal.
detail.extracted describes HTML extraction. Both hashes and the verification
result remain recorded when a later admission screen withholds the text.
The crossing repeats the hashes; matching them to the preceding transform
record ties the observation to that acquisition without a sequence-number
join. The raw credential and source text are not copied into the source
record. Verification establishes no factual accuracy or licensing authority
and changes no admission rule. These private processor details are not
projected to Content Telemetry.
Acquisition handles
Section titled “Acquisition handles”A host opts into explicit host observations before any mediated
crossing (including a refusal). Earlier observed hook crossings are allowed.
It opts in by sending the MCP JSON-RPC request commonmeasure/observe with
{"event":"observations_started"}, or by starting the stdio server with
--host-observations, which records the same request’s record before the
session’s first crossing; a log that already holds a crossing stays as it
was recorded. Claude Code’s plugin uses the flag
(host integration §Context entry and citation on Claude Code). This is a host integration method, outside
tools/list; it is not a model tool. Its acknowledgement follows the durable
append. The method accepts only the records below and rejects unknown fields.
CM supplies the session, host, timestamp and working directory. The caller
cannot assert a different policy, source URL, observer or grade. The first
observation request records available credential provenance and supplied client
identity through the same session-start path used by tools.
Opening a log discovers opt-in from complete, parseable lines, tolerating partial
or damaged lines so hooks can continue appending and the MCP server can start.
It parses only a line whose bytes hold the event name observations_started,
which the writer never escapes, so opening a session that never opted in parses
nothing. Opening writes nothing; the first append marks a torn final line
(§Reading a log).
Observation validation reads the durable file by the strict rule of §Reading a
log and returns unavailable on a line that does not parse and is not a marked
tear, or on a final line cut short that no append has marked yet. It waits for
an append in progress. Each request reads the lines appended since the previous
one and checks the request against a validation index of the earlier ones: the
handles, generation, output and action identifiers, hashes and byte offsets the
checks need, and no URL or text. The index is derived from the log and is not
evidence. The writer holds it in memory and journals it to
<home>/observation-index/<session-id>.ndjson, so a restarted server resumes
from it. Each journal entry names the byte range of the log it covers and a
SHA-256 of the 4,096 log bytes before the end of that range. A journal that is
missing, malformed, discontinuous, of another version or about another log is
discarded and the index is rebuilt from the log; a journal that cannot be
written changes no answer. Only a turn identifier that is a canonical UUID is
indexed, so a host’s own turn identifier never reaches the journal. A journal
loaded with more than 1,024 entries is replaced by one entry at the next
request. A malformed record stops the index at that record, so later requests
stay unavailable. The index does not reread bytes it has indexed: damage to
them, outside the digest’s 4,096 bytes, is found by a strict read of the log
(commonmeasure session), not by a later observation request.
The journal is not authenticated: once its range and digest match the log its facts are taken as given, and anyone who can read the log can compute the digest. That is the log’s own trust limit and no new boundary, because the log is unauthenticated and sits beside the journal under the same user, so whoever can write the journal can append the same claim to the log. Both are protected by the operator home’s file permissions, and the journal and its directory are created owner-only. A forged log line stays in the evidence; a forged journal fact leaves no record of itself.
Explicit observations cover context_fetch only. context_search deliveries
return no acquisition handle and retain their existing crossing semantics; they
are not stamped host_required and cannot receive a context-entry observation.
In this mode each admitted text fetch, and each delivered file, carries an
acquisition_id (UUID) and a separate crossing_id (UUID) on its
crossing_mediated record. observer is cm, grade is mediated, and
context_observation is host_required. Where the call named the host’s own tool
call it answers (host_call_id, host integration §The parts of a fetch),
the record carries it as host_call_id, so the host’s later observation of
that call is bound to this acquisition by the edge’s record rather than by
the call’s result. It stays in the private source record. The
fetch response returns the acquisition handle only after its record has been
fsynced. Failure to record it makes the fetch unavailable. Refusals and failed
fetches yield no handle. A fetch whose answer this edge could not record
carries failure and is not stamped host_required. The handle identifies the
admitted bytes named by content_hash; it is neither supplier-issued delivery
identity nor a licence. A repeated acquisition receives a new handle, even for
identical bytes.
grounded is false on these crossings. Retrieval establishes no context entry.
Older sessions and integrations which have not opted in retain their existing
crossing semantics. The handle remains usable by later generations in the same
session. Cross-session references and unknown handles cannot establish use.
Context-entry observations
Section titled “Context-entry observations”commonmeasure/observe accepts:
{"event":"context_entered","acquisition_id":"<UUID>", "generation_id":"<UUID>","representation_hash":"sha256:<64 hex digits>"}CM appends context_entered with observer: host, grade: observed, and the
host identity of the MCP process. It checks that the handle belongs to an
admitted acquisition in this session. The representation hash identifies the
bytes the host observed in its serialised model request, including any host
transformation; the acquisition’s hash remains unchanged. For an acquisition
that handed a file over, representation_hash is the SHA-256 of the file’s
bytes as delivered, the crossing’s content_hash, whatever encoding the
request used; the edge refuses any other with -32602, and records nothing.
Equality ties the host’s claim to the delivered bytes. The fetch result gives
the host that hash, so equality does not show the file entered the request
or which of its pages the model read; the observation remains the host’s
claim. No text is accepted.
The observation establishes the host’s request boundary, not provider-internal context or
model execution. Transport failure can leave an attempted request without a
context-entry observation. History reuse records a new generation against the
same handle, without another retrieval. Duplicate observations for the same
handle, generation and representation are refused.
Each eligible context-entry observation is projected as a grounding event (telemetry projection §Grounding from host observations).
Output-association observations
Section titled “Output-association observations”For each completed assistant message the host sends:
{"event":"output_associated","generation_id":"<UUID>", "output_id":"<UUID>","output_hash":"sha256:<64 hex digits>", "acquisition_ids":["<UUID>"]}The record names observer: host and grade: observed. It records an explicit
reference or quotation association under the host’s convention, never semantic
support. The hash identifies the output; output text and quotation text are
not accepted. An empty list records that the observed output had no recognised
association. Unknown handles are retained as unresolved references and never
count as source use. Repeated markers count as repeated associations. A known
handle absent from that generation’s observed context is also unresolved for
projection. At most 1024 association references are accepted per output.
The output observation and completion boundary are separate durable appends. If the first succeeds and the second fails, the call returns unavailable. An identical retry from the same host, with the same generation, output ID, hash and ordered association list, appends only the missing boundary. A changed retry or an output whose generation already has a completion boundary is refused. A malformed durable log remains unavailable; retry does not repair it.
The ingress also writes a minimal turn_completed boundary with the generation
as its host turn identifier and a reference to this output observation. This
boundary means the generation completed; it does not assert that the user’s
whole task ended. This host-observation boundary carries observer, grade,
generation_id, output_id, minimal privacy fields, detail.cwd and the MCP
process’s policy_identity when set. It does not carry the hook boundary’s
policy-resolution fields or transcript path. Clearance uses its working
directory independently of the acquisition. A retry uses the completing
process’s working directory and effective policy identity.
The projection counts the boundary’s associations in a custom field
(telemetry projection §Custom fields),
and projects each eligible reference as a content_cited, one per distinct
source (telemetry projection §Citation from host observations).
The hash, local handles and output ID stay in the private source record. The
separately appended boundary lets a later output be projected after its
grounding was already spooled, without changing a previously delivered
event’s count or identity.
Host action decisions
Section titled “Host action decisions”The host records its decision at the action boundary through
commonmeasure/observe, before executing an allowed action or returning a
refusal to the model:
{"event":"action_decided","action_id":"<UUID>", "generation_id":"<UUID>","acquisition_id":"<UUID>", "action":"send_acquired_text","decision":"refused", "rule_id":"recipient_not_authorised", "target":{"url":"https://shared.example/inbox/other","recipient":"other"}, "data_hash":"sha256:<64 hex digits>", "host_policy_hash":"sha256:<64 hex digits>"}This first action kind sends exactly the acquired text. CM validates that the
acquisition was admitted in this session, its content hash equals data_hash,
and its handle has a context-entry observation for this generation. It rejects
duplicate action IDs; an action has no identical-retry path. decision is
allowed or refused; rule_id names the host rule applied. CM supplies
observer: host, grade: observed, host, session
and timestamp. The host cannot supply those fields or source-policy identity.
The acquisition retains CM’s admission, effective source policy and applicable
licence/declaration conditions; the action record does not replace them or turn
them into permission to disclose. The observation is the host’s assertion, not
CM’s independent verification of its decision or its enforcement.
An action decision refers to the generation whose output proposed the action,
so it may follow that generation’s output_associated and turn_completed
records. A context entry is refused once its generation has an output; an
action decision is not. Where the tool call that proposes an action ends the
assistant message and the host records that output before it executes the
tool, every action record follows its generation’s completion boundary.
host_policy_hash identifies the host’s action policy, separately from CM’s
source policy. target identifies both the destination resource URL and the
recipient. The URL must be absolute HTTP(S), at most 2048 bytes, without user
information, query or fragment. Its authority and path come from the host and
are bounded only by that length, as a crossing’s URL is; CM cannot tell a
resource path from text placed in one, so hosts must not put credentials or
private text there. Recipient and rule identifiers are 1–128 ASCII letters, digits, dots,
underscores or hyphens. Both hashes use the SHA-256 form above. Unknown fields,
other action kinds and other decisions are rejected. The record has no field for
a prompt, acquired text, file contents or action arguments; the URL path is its
only free-form value.
An acknowledgement follows the fsynced append. Failure makes the action
unavailable. An allowed decision
records permission only; it does not establish transmission, recipient receipt,
subsequent access or fulfilment of licence conditions. A missing decision does
not establish refusal. Action records stay in the private source record; the
relay projects no event, boundary, source-use count or refused-acquisition count
from them, irrespective of source or session clearance.
The controlled Pi fixture uses an operator-supplied grant for an exact source
URL, destination URL and recipient, with redirects refused. The model may
propose an action but cannot extend that grant. Tests admit useful synthetic
content with an attribution condition and an embedded disclosure instruction,
observe its context entry, and exercise refusal of another origin and of an
unauthorised recipient or resource on an allowed shared host. An action of the
same shape to the authorised recipient and resource succeeds. These claims are
fixture-tested, with synthetic model responses and loopback HTTP captures.
They establish neither live model resistance nor process-wide egress control.
The host records recipient_use as unavailable because subsequent access,
retention and use at the recipient are outside its observation.
Unavailable host observations
Section titled “Unavailable host observations”The ingress accepts {"event":"observation_unavailable","observation":"…"}
for the fixed categories provider_context, provider_cache,
supplier_delivery_identity, semantic_support, process_egress and
recipient_use.
CM appends the named gap with observer: host, grade: unavailable and a fixed
explanation. These are unavailable observations, never zero measurements and
never telemetry events. Absence of a later context or output record alone
establishes no negative claim: the host or process may have failed before it
could record one. Partial assistant outputs are not counted as completed
output associations.
The ingress and the projection of these observations are fixture-tested:
crates/commonmeasure-harness/src/observations.rs tests the ingress,
crates/commonmeasure-cli/tests/mediated_e2e.rs drives it through the
binary, crates/commonmeasure-cli/tests/cited_e2e.rs drives Claude Code’s
Stop hook path through it, and crates/commonmeasure-relay/src/project.rs
tests the projection.
The content and model responses are synthetic. Supplier authorisation,
provider-internal events, independent receiver acceptance and delivery recovery
are separate evidence requirements.
Importing history
Section titled “Importing history”commonmeasure import --dry-run # what would be read, per hostcommonmeasure import --since 2026-06-01Only what a transcript evidences is imported. A WebFetch with a
recorded result becomes one grounded crossing; a WebSearch becomes one
ungrounded crossing per result link; a call the transcript never shows
completing becomes nothing.
A host whose history evidences no crossings is reported as not importable,
with the reason, rather than yielding an empty result. Codex reaches the
network by shelling out: its transcripts hold exec command text, and a URL
inside a command is not evidence that anything was retrieved. Importing those
would fabricate crossings, and the purpose of this store is that its
crossings are trustworthy. Pi has no web tools of its own; a mediated tool
result in its history was recorded when it happened, and a transcript copy
would be a weaker duplicate of an existing record.
grounded is true only when page text entered the model’s context. WebFetch
grounds. WebSearch and third-party MCP results do not: the model saw titles
and snippets, and nothing ties a returned URL to bytes that entered context.
Asserting grounding there would put a citation in the record with nothing
behind it.
content_hash is SHA-256 over the ingested bytes as they stand rather than
over a JSON document, so the canonical form
(docs/contracts/canonical-json.md) does not reach it. It covers the text as
ingested, which for a Claude Code WebFetch is the tool result’s result
field and not the surrounding {"bytes": …, "code": …} wrapper. The model’s context and the transcript carry
only result; a hash over the wrapper can never be matched against either, so
it would break any later citation check.
estimated_tokens always travels with token_basis. Four characters to a
token is an approximation, and a number labelled “tokens” that no tokeniser
produced claims a precision it does not have.
agent_type and agent_id appear only when the crossing happened inside a
subagent. They are the host’s own discriminators, read from a hook payload or a
transcript; a main-agent call carries neither, and neither is inferred from
payload shape. A mediated crossing also carries neither: the MCP server is
called the same way from a subagent and from the main agent, and the host tells
it nothing that would distinguish them.
turn_id is the host’s own identifier for the turn the crossing happened
in (Claude Code sends prompt_id on every hook payload; Codex sends
turn_id), carried so a reader can group crossings under the turn boundary
that shares it (§Turn boundaries). It appears only on observed crossings: the
MCP server is told nothing about turns, and a transcript’s turn structure is
not read back on import.
principal and authentication_basis appear only on mediated crossings. They
name the identity resolved before admission and the basis that authenticated
it. Observed and reconstructed capture happened outside that enforcement path
and claims neither, even if the host payload contains an agent label.
authentication_basis is one of four values. os_user is where the runtime
read the process’s effective operating-system user, and unavailable where
the platform offers no identity it can read (currently every non-unix
platform, including the Windows binary the release publishes). unavailable
is recorded rather than omitted, and it is not a downgrade in enforcement: a
policy file declaring principals fails closed there, refusing every mediated
crossing and saying so, and a policy file declaring none is governed by
directory scope alone (trusted principal resolution,
docs/GLOSSARY.md §Principal). The other two are the
hosted edge’s (docs/contracts/host-integration.md
§1, the bearer token): oauth_subject where a hub-issued access token
verified, with principal the sub claim as subject:<sub> unless a
policy binding keyed subject names a label for it; and edge_token where
a token the edge issued verified, with principal token:<label> unless a
binding keyed edge_token names a label
(docs/contracts/source-policy.md §Scopes and
principals). A hosted session carries cwd only when the operator explicitly
declares its service directory (§Hosted scope).
licence.state is unknown on observed and reconstructed crossings: no host
tool reports rights, and reaching a page is not permission. A mediated fetch
records what the source declared (§Source declarations).
cwd is the directory the session was working in when the crossing happened.
It is recorded on the crossing itself, not only on turn boundaries, because
sessions cross worktrees. It is a fact only: which engagement that directory
is reported under is resolved at read time in the sink from
$COMMONMEASURE_HOME/attribution.json (ordered substring rules over the cwd,
first match wins, unattributed otherwise). Editing a
rule re-attributes history without rewriting any evidence. No engagement
label enters this log. Observed and mediated crossings carry a cwd;
reconstructed ones do not, because a directory read back from a transcript
would be a claim, not a witnessed fact. A hosted session records the
directory its service declares (§Hosted scope), and none where the service
declares none; the process’s own directory is never inferred.
Older logs without the field read as absent.
policy_scope names the scoped policy overlay (scopes in policy.json,
matched against the cwd when the server started, first match wins) that
governed a mediated crossing; a record enforced under a policy that cannot be
named cannot be audited. It is absent when the top-level policy applied, and
always absent on observed and reconstructed crossings, because nothing
governed those before they happened. Policy is resolved at capture time and
attribution at read time, the opposite of each other, because a strict rule
for one engagement and observe for another cannot be a projection: refusal
happens at the crossing.
The same matched scope may bind its cwd to a named engagement (the
governing engagement) and set allow_telemetry_egress explicitly. This is
read-time relay policy, not evidence added to a crossing: the relay resolves
each crossing’s recorded cwd immediately before projection. Absence, no
match, no engagement name, and an omitted or false clearance all mean no
egress. A malformed policy makes the relay unavailable before projection; it
never becomes an unfiltered successful mode.
The governing engagement and the reported engagement are two identities: the
governing engagement is declared in policy.json and is the only one an
enforcing component can read (Attribution is in commonmeasure-console, above both the
relay and the harness); the reported engagement is the attribution projection
and is what every console figure counts under. Neither derives from the other
and neither enters this log. They are expected to agree; nothing enforces it,
and the console, the only reader of both, states where they differ and
prefers neither.
refusal is set only on crossing_refused, which only a mediated crossing can
be.
policy_identity is set on every mediated crossing and on every refusal,
and on no observed or reconstructed crossing, for the reason principal
is: only the mediated path met a policy. It is defined in §Policy identity.
breach is set on a mediated crossing that broke a constraint the operator’s
mode chose to carry rather than refuse. The mode decides what happens to a
breach, never whether it is recorded (docs/FAIL-POLICY.md): under observe or
prefer the crossing goes ahead with the broken constraint named here, where
strict would have refused it. A PII finding is recorded in the same field
in every mode, strict included: the detector refuses nothing, the crossing
is admitted with the finding named here, and the finding’s categories and
offsets in the processor_invoked record are the evidence a reviewer needs.
Observed and reconstructed crossings never carry a breach, because nothing
judged them before they happened; for the same reason the PII detector scans
only crossings the mediated path carries, and its manifest names that blind
spot.
Source declarations
Section titled “Source declarations”A mediated fetch reads what the source declares about AI use and records it on the crossing, with three more fields:
http_status— the status the final URL answered with. Absent for a search result, for a transport failure and for a refusal made before any request.failure— the client’s account of a transport failure, when the request left the machine and nothing usable answered, or of this edge’s own reason for not sending it. Besidehttp_status, the host answered and this edge could not record the answer in its back-off store, or could not date the host’s nextCrawl-delayturn from it, so the answer was not used;failuresays so and names the repair.declarations— what was read, from where, and what it adds up to.
The reader (crates/commonmeasure-harness/src/declarations.rs, the cache and
record in crates/commonmeasure-harness/src/discovery.rs) gathers
statements, each a source, a category (train-ai, ai-input,
ai-index, search), a preference (allow or disallow) and the
mechanism’s own label. The sources are the page response’s Content-Usage
header (content-usage-header), a Content-Usage rule in the selected
robots.txt group (robots-content-usage), a Content-Signal line in that
group or, where it has none, one outside any group (robots-content-signal),
and an RSL licence’s usage terms (rsl-licence). The CommonMeasureBot group
is selected where the file names it and the * group otherwise; the record
names which. A License: or Content-Signal line belongs to the group whose
lines it follows without a blank line or a Sitemap: line between; after such
a break, or before any group, it is site-wide. Statements combine
most-restrictive-wins per category into effective; a category no statement
addresses is unknown, never disallow.
"declarations": { "robots": {"requested_url": "https://publisher.example/articles/1", "url": "https://publisher.example/robots.txt", "final_url": "https://publisher.example/robots.txt", "cache": "fetched", "fetched_at": "…", "expires_at": "…", "status": 200, "reading": {"group": "CommonMeasureBot", "group_is_wildcard": false, "crawlable": true, "access_rule": "Allow: /", "access_rule_wildcard": false, "statements": [], "licences": ["https://publisher.example/license.xml"], "crawl_delay": {"value": "2", "delay_ms": 2000, "honoured_ms": 2000, "capped": false}}, "mode": "strict", "outcome": "allowed", "delay": {"host": "publisher.example", "delay_ms": 2000, "outcome": "waited", "wait_ms": 1732, "budget_ms": 60000}}, "redirects": [{"requested_url": "https://short.example/abc", "url": "https://short.example/robots.txt", "cache": "fetched", "fetched_at": "…", "expires_at": "…", "status": 200, "reading": {"group": "*", "group_is_wildcard": true, "crawlable": true, "access_rule": "Allow: /", "access_rule_wildcard": false, "statements": [], "licences": []}, "mode": "strict", "outcome": "allowed"}], "licences": [{"url": "https://publisher.example/license.xml", "mechanism": "robots-license", "cache": "fetched", "status": 200, "content": "/", "terms": {"statements": [], "payment": {"kind": "use", "amount": {"currency": "USD", "decimal": "0.015"}}, "offer": 1, "reporting": [{"kind": "telemetry", "profile": "https://contenttelemetry.org/profiles/spur", "endpoint": "…", "config": {"conformance_level": "grounding"}}]}}], "content_usage_header": "train-ai=n", "statements": [{"source": "content-usage-header", "category": "train-ai", "preference": "disallow", "detail": "Content-Usage: train-ai=n"}], "effective": {"train-ai": "disallow", "ai-input": "allow", "ai-index": "unknown", "search": "unknown"}}content_usage_header is the header’s text as received. Where the value
holds bytes that are not UTF-8 (obs-text, RFC 9110 §5.5), the record keeps a
rendering of it, each byte from 0x80 written \xHH and each backslash
\\, and content_usage_header_rendered is true; otherwise it is absent.
The flag is what tells the byte 0xE9, recorded \xE9, from ASCII text that
reads \xE9, recorded as itself. The same flag is content_type_rendered
beside a crossing’s content_type (§Crossing, a fetched PDF) and beside
content_type in the extractor’s processor_invoked detail.
robots.txt and the licences it names are read before the request, so a
statement they carry is ruled on before any bytes move; the header and a
Link: rel="license" licence are read from the response, so a statement they
carry is ruled on after, and the bytes are withheld from context until it
has been read. The host’s cache file keeps, per page URL, the licence the
page’s Link header named when it was last fetched (page_licences), so a
later crossing to a host that states a delay reads it before the page. A
response whose Link names a licence this edge cannot read (below) leaves
that entry as it was. A
robots.txt answer is used for 24 hours, or for the response’s max-age
where shorter, and asked for again once it has expired. It is cached per
origin (scheme, host and port) under
$COMMONMEASURE_HOME/declarations/<key>.json, beside the licence documents
it names: <key> is the host for https on port 443, and
<host>_<scheme>_<port> for any other origin, so two origins on one host
never share a copy or a held answer. The host is read in lower case with a
trailing dot removed, so a.example.com. and a.example.com send the same
requests and share one entry (cache says fetched, reused, or not_asked where the host’s
Crawl-delay put the probe beyond what the fetch may still spend waiting, or
the crossing was refused before the licence was reached); a probe that
failed, or that the host answered outside 2xx (and, for robots.txt, with
429 or outside 4xx), is cached for five minutes, with the failure in its
record’s unavailable field and the answer in status; a licence that
answered 404 or 410 is cached for five minutes too, and asked again after
them. A probe that failed for a reason of this edge’s own (cut_short,
below) is not cached as the host’s failure: its record expires when it is
written, and the next crossing asks again. A licence probe that was not asked for is remembered only until the
next crossing. A probe
that waited out a turn is dated when it was sent, not when the wait began.
A licence is unread (true) where it exists and could not be read: it was
not asked for, the request failed or timed out, the host answered outside
2xx other than 404 and 410, the body was over the bound or did not parse as
RSL, or choosing its <content> entry for the page takes more work than
this edge does for one page (below). A 401, 403, 451 or any other 4xx but 404 and 410 says the document
exists and is withheld from this fetcher, so it is unread. A 5xx is
unread. Redirects are followed where robots.txt at the target’s origin
allows them (below), and the final status decides. A licence
the source named that answers 404 or 410 is missing: status holds the
code, unavailable says the declared licence is missing, and missing is
a gap (reason: "evidence_missing") naming the URL and the status; the
licence has no terms and is not unread. The same status and missing
are on the licence in the tool result’s declarations.licences. A licence
that was read and has no <content> entry for the page is not unread; it
names no terms for that page. For robots.txt, a 4xx other than 429 is not
a failure: the host publishes no rules for this fetcher (RFC 9309 §2.3.1.3),
and the answer is cached as a file is. A failed robots.txt probe, or one this
edge did not send or cut short, never loses the origin’s last answer: it is
kept under robots_held in the cache file while robots holds the failure
or nothing, and dropped once the host answers again. Cache files are written
whole and renamed into place. Every probe goes through the same host policy
and address floor as the page, and no probe is ever recorded as a crossing.
A request the edge makes on its own account, and not the agent’s, is ruled
by robots.txt as a page is. The manifest probe, the probe of the
registrable domain’s manifest (§Manifest discovery) and a licence that only
the page’s Link header names are each ruled, before they are sent, by
robots.txt at their own origin for the CommonMeasureBot group, or *
where no group names it, in every policy mode. So is every redirect from any
of these probes and from a licence, at the redirect’s own origin. That file
is read through the cache above, as a page’s is: it takes no Crawl-delay
turn, a live copy is reused, and a request for it observes back-off. A
Disallow for the URL, or a file that cannot be reached with no answer held,
refuses the probe, and it is not sent. A licence named by a License:
directive in a robots.txt is fetched without that check where it is at the
origin of the page whose robots.txt was requested (scheme, host and
effective port, the host read as the cache key reads it), because that file
could have refused it; a redirect from it is still ruled. A licence the
directive names at any other origin, including the origin a redirected
robots.txt was served from, is ruled by robots.txt at that origin, as a
licence the Link header names is: naming it there does not exempt it. A
robots.txt probe’s own redirects are followed and not ruled (RFC 9309
§2.3.1.2).
A licence refused this way, whether the page’s Link header or another
origin’s License: line named it, carries refused_by: "robots.txt",
unread: true and the rule that refused it in unavailable; cache is
not_asked, or fetched where the licence was asked for and answered with
a redirect that was refused. The page is treated as having no readable
licence, and where governing is statements an unread licence refuses
the crossing in every mode. A licence a License: line names is read before
the page, so its refusal refuses the crossing before the page is requested.
A licence the Link header names is ruled on from the page’s response,
including one read before the page on a host that states a delay because the
last response named it: the page is fetched, and its bytes are withheld
while the response names that licence. The refusal is cached until the copy
of robots.txt that refused it expires.
{"url": "https://licences.example/rsl.xml", "mechanism": "link-header", "cache": "not_asked", "refused_by": "robots.txt", "unread": true, "unavailable": "https://licences.example/robots.txt disallows CommonMeasureBot at https://licences.example/rsl.xml: the `User-agent: *` group, which addresses every fetcher because no group names CommonMeasureBot; rule `Disallow: /`, a literal path prefix"}Every Link field of the response is read, from its bytes. A member’s
target and its rel and type parameters are all this edge reads
(RFC 8288 §3); a byte that is not UTF-8 in any other parameter, such as
title, or in a member that names no licence, changes nothing. rel* and
type* are read as rel and type once their RFC 8187 encoding is
decoded, in UTF-8 or ISO-8859-1. The first of each of rel, rel*, type
and type* is read and a later one is ignored. A member with both rel and
rel*, or both type and type*, is read with each: RFC 8288 Appendix B.2
step 16 has the starred parameter replace the plain one, a parser that does
not apply it reads the plain one, and where the readings disagree about
whether the member names an RSL licence the edge cannot know which the
publisher meant, so the member is an unread licence. A member that
cannot be parsed ends at the next comma outside a quoted string or <…>,
so it costs no other member unless it leaves a quote or < open. A member
mentions license where the word is in its bytes with quoted-pair escapes
removed (lic\ense), or in its rel or type as decoded. A member is an
unread licence where:
- it names a licence of type
application/rsl+xml, and its target is not UTF-8 or does not resolve to a URL; - its
relmentionslicenseand is not UTF-8, or it names a licence and itstypeis not UTF-8, and the other parameter does not rule out an RSL licence; - its
rel*does not decode and the member mentionslicense, or it names a licence and itstype*does not decode, and the member’s plainrelortypein its place does not name an RSL licence (where it does, that licence is read); - its
relandrel*, or itstypeandtype*, disagree about whether it names an RSL licence; - a parameter name is not a token, and the member mentions
licenseand lacks arelor atype, which that name may have been; - it cannot be parsed and mentions
licenseanywhere; - it names an RSL licence after another member of the response named a different one. The first readable RSL licence is read; this edge reads one licence per page, so a second is unread and withholds the page rather than being passed over. Two members resolving to the same URL name one licence.
It is recorded with mechanism: "link-header", cache: "not_asked",
unread: true and the reason in unavailable, and nothing is requested
for it. url is the target as written, or the whole member where it could
not be parsed, since a target read before the fault may not be the
licence’s, and for a second RSL licence the URL it resolves to; where it
is not UTF-8 it is the rendering described above and url_rendered is true, so the byte 0xE9 and ASCII text reading \xE9 are
told apart. url_rendered is absent otherwise. Like any unread licence it
withholds the page in every mode. The tool result names such a licence by
position, since its url is the source’s text, unless the url is an
http or https URL exactly as the URL parser writes it, such as a second
RSL licence’s, which is shown as any source URL is
(host integration).
A licence named by a URL whose scheme is not http or https (data:,
urn:), from a Link header or a robots.txt License: line, is unread:
this edge requests nothing else, so nothing is requested for it and no
origin’s robots.txt is read for it. Its url is the URL whole and
unavailable says why.
{"url": "/licen\\xE9e.xml", "url_rendered": true, "mechanism": "link-header", "cache": "not_asked", "unread": true, "unavailable": "the Link member names an RSL licence and its target holds bytes that are not UTF-8, so no URL is read from it"}robots is the access-rule attribution for the URL the crossing names.
requested_url is the URL the rules were applied to; url is the
robots.txt at its origin; final_url is the URL that answered the
request for it, after redirects, and differs from url where the file
redirected; reading.group is the group selected, with
group_is_wildcard true where the * group was used because no group
names CommonMeasureBot; reading.access_rule is the Allow or Disallow
line that decided, as written, with access_rule_wildcard true where its
pattern uses * and so matched by wildcard rather than as a literal prefix
(for a path past the readings cap, below, it names the cap and
access_rule_wildcard is absent);
mode is the policy mode the session was in and outcome what the rule
did: allowed, refused (a Disallow, before the request), unreachable
(the file could not be reached and no answer was held, so every path is
disallowed and the crossing stopped before the request), cut_short (this
edge’s own request for the file failed and no answer was held, so the
crossing stopped before the request; below) or unavailable
(this edge did not request the file, because the operator’s policy, the
address floor or the hub’s origin stops requests to its origin; nothing was
held, no rule was applied, and the page, at the same origin, is refused by
the same check). A Disallow for the selected
group binds in every policy mode, so refused is written under
strict, observe and prefer alike, and mode records the session’s mode
without having decided anything. The refusal text and the agent’s
declarations.robots.explanation carry the same attribution in words; the
refusal ends “A Disallow binds in every policy mode: refused before the
request.” A refusal on the access rule ends with the source’s file, “(the
source’s robots.txt, <url>; …)”, where other refusals name the operator’s
policy file. Two refusals on the access rule name something else, because
the source’s file is not where the reader can act. A file unreachable
because its redirect went to a host the operator’s policy refuses, or to a
private address the policy could admit, ends “(operator policy in <file>,
which does not admit <target>, where the source’s robots.txt redirected;
…)”. A file this edge cut short ends “(this edge did not read <url>, and
the next crossing asks for it again; …)”.
A robots.txt over 512 KiB is parsed up to its last complete line within
512 KiB, and the rest ignored (RFC 9309 §2.5). truncated then gives size, the bytes the host sent after any
content coding was removed, and read, the bytes parsed, and the
explanation says how much was read. Where the reading came from a held copy,
truncated describes that copy. A first line longer than 512 KiB leaves
nothing to parse, so no rule applies. A body over the HTTP client’s 32 MiB
ceiling is a failed request, and so unreachable.
"truncated": {"size": 614437, "read": 524270}A robots.txt that cannot be read is ruled as RFC 9309 §2.3.1 says for a
crawler, in every mode. status is what the file answered, where it
answered. unreachable is true where the file answered 429 or a 5xx (or
any status that is neither 2xx nor 4xx), or where a timeout, name, TLS or
connection failure left no answer, or where the file redirected to a URL
this edge declines to follow; unavailable then names the failure, and a
probe timeout reads “did not answer within the 5s exchange budget”.
A declined redirect is one to an address this edge does not mediate, to the
hub’s origin, to a host the operator’s policy refuses, or to a host in
back-off. Like any failure it is cached for five minutes, so while the
target’s back-off lasts the file is not asked for on every crossing. unavailable
reads “<file> redirected to <target>, which this edge does not follow:
<reason>”, declined_redirect names the target, and the refusal cites RFC
9309 §2.3.1.2 beside §2.3.1.4. §2.3.1.2 expects a crawler to follow at
least five redirects; this edge follows five, and a sixth leaves the file
unreachable (“redirect limit exceeded”).
Under observe and prefer the host policy records a breach rather than
refusing, so a robots.txt redirect to a host outside an allowlist is
followed there, and the file it reaches rules the page (§2.3.1.2).
final_url names that file, and the explanation and any refusal name it
too: “<url> (redirected to <final_url>) disallows …”. The crossing that
sent the request carries the host policy’s breach in breach, as a
crossing whose page redirected to such a host does: “<url> redirected to
<final_url>, which was requested for its rules: <reason>”. A later crossing
that reuses the cached copy sends nothing to that host and carries no
breach for it.
Where the cache holds an earlier answer from the host, reading is read
from it however old it is, held_copy names it (fetched_at,
expires_at, status, and final_url, the URL that gave it), and
outcome is what its rules say. Where none is
held, reading is absent, outcome is unreachable, and the refusal names
the file, the host and the failure, and ends “An unreachable robots.txt is
a complete disallow in every policy mode: refused before the request.”
A cached robots.txt probe is reused, while it is live, only in a shape
this edge writes: a file, with truncated where it was over 512 KiB; a 4xx
other than 429; or a failure with its reason. Any other entry, such as a
2xx kept without its file, is asked for again rather than reused. A
robots.txt probe this edge did not send (not_asked) is asked for again
on the next crossing: the rule that stopped it is this edge’s own and is
checked again before any request.
A probe can fail for a reason of this edge’s own rather than the host’s:
the whole-call time limit (below) left no time for the request, or the
request was given only what was left of that limit, less than the 5 s
exchange budget, and had not answered when the limit was used up; or the
request could not be signed. The host is not held to have failed. The file
is not unreachable: cut_short is true, unavailable names the reason,
and the cache record expires when it is written, so the next crossing asks
again. A held answer rules as it does for an unreachable file. With none
held, outcome is cut_short and the crossing is refused before the
request in every mode, because a page is not fetched under a robots.txt
this edge has not read. The refusal ends “: refused before the request, in
every policy mode.” A licence probe cut short is unread, and so refuses
the crossing, and is asked again at the next crossing; a manifest probe
cut short is unavailable and is not kept. Whether the request was sent is
recorded apart from why it failed: a probe cut short before transport (no
time left, or no signature) has cache: "not_asked", and one the time
limit ended after it was sent, or whose redirect target was not requested,
has cache: "fetched".
"robots": {"requested_url": "https://publisher.example/private/report", "url": "https://publisher.example/robots.txt", "final_url": "https://publisher.example/robots.txt", "cache": "fetched", "fetched_at": "…", "expires_at": "…", "status": 503, "unavailable": "https://publisher.example/robots.txt answered 503", "unreachable": true, "held_copy": {"fetched_at": "…", "expires_at": "…", "status": 200, "final_url": "https://publisher.example/robots.txt"}, "reading": {"group": "*", "group_is_wildcard": true, "crawlable": false, "access_rule": "Disallow: /private/", "access_rule_wildcard": false, "statements": [], "licences": []}, "mode": "observe", "outcome": "refused"}carried is no longer written. Records from earlier releases, in which
observe and prefer made a disallowed request and recorded the breach with
outcome carried, still read, and a reader treats carried as a Disallow
that was not obeyed. Preferences read from robots.txt
(Content-Usage, Content-Signal) are not the access rule, and a statement
that disallows AI input refuses in every mode as well (below).
A robots.txt Allow, Disallow or Content-Usage path, and an RSL
<content> url, relative or absolute, is compared with the page’s URL
with both sides in one form (RFC 3986 §6.2.2):
- the host in lower case, IDNA-encoded, without trailing dots, a default port or credentials (absolute scopes only);
- a percent-encoded unreserved character (
A–Z,a–z,0–9,-,.,_,~) decoded, so/%6Eews/1is/news/1; - every other percent-encoding kept, with upper-case hex, so
%2fis%2F; an encoded*or$is never a wildcard or an anchor; - a literal
*or$in the URL encoded as%2Aor%24, the spelling in which a rule names one (RFC 9309 §2.2.3), so/file-*.htmlis under/file-%2A.html; a$that is not the last character of a rule is a literal and is encoded too; - a character outside the URI character set percent-encoded, so a rule
written
/café/covers/caf%C3%A9/; - in a URL, dot segments resolved as a URL parser resolves them
(
/a/%2E%2E/bis/b, the path the edge requests) and the fragment dropped. A dot segment written in arobots.txtpattern is compared as written.
A rule is read as written: its slashes are never merged, its %2F and
%3B are never decoded and its ; is never stripped, so Disallow: //
covers only a page whose path, or a reading of it, starts with a doubled
slash, and /a%2Fb does not cover /a/b. The page’s path is read as
requested and as every path four operations reach from it, in any order,
any number of times, and including only some of them, because servers
differ in which they apply and in what order:
- decode:
%2For%5C(either case) read as/, and%3B(either case) read as;, as a proxy that decodes the path before a Java server does, and then stripped; - merge: each run of
/read as one; - resolve: dot segments resolved as the URL parser resolves them;
- strip: in each segment, everything from the first raw
;to the end of the segment removed.
Each reading is in the form above, and the query is the same in every
reading. nginx merges before it resolves and serves /x//..%2Fnews/1 as
/news/1; the URL parser resolves first and reaches /x/news/1; both are
readings, and so is /x//../news/1, decoded and not resolved. Tomcat,
Jetty and Undertow strip path parameters, so /news;x=1/1 reads as
/news/1, and /x/..;/news/1 reads as /x/../news/1 and then /news/1.
nginx, when proxy_pass names a URI, decodes %3B before a Tomcat behind
it strips the ;, so /news%3Bx/1 reads as /news;x/1 and then
/news/1. IIS is documented to read %5C as /; nginx and Apache serve
/news%5C1 as a file named news\1, so there a %5C reading refuses more
than the server serves under the rule. A ; or %3B reading refuses more
in the same way on a server that strips no parameters, where /news;x/1
and /news%3Bx/1 name a directory news;x.
Each operation leaves the path unchanged or shorter, so the readings are
finite. Readings are capped at 64, and a crafted path can reach the cap:
/x//..;/.../;/..;/a/%2Fb/;//%2F..%2F..%2F;%5Cc has 65. Every path of a
/ followed by up to six of /, %2F, %5C, .., ., a, ; and
%3B has at most 28 readings, and at most 24 as the URL parser leaves it.
A page past the cap is refused without comparing its readings, as if a
Disallow covered it: reading.crawlable is false, reading.access_rule
names the cap and no group is recorded. Such a URL is internal under any
internal prefix on its origin. How the readings combine:
- access rules: a page is refused when any reading’s longest matching rule
is a
Disallow, andreading.access_rulenames that rule. AnAllowreached by one reading does not lift aDisallowreached by another, so/a//bis refused underDisallow: /a/andAllow: /a/b, and//news/1underDisallow: /andAllow: /news/; Content-Usage: the rules each reading selects all apply and combine most-restrictive-wins, as tied rules do;<content>entries: the entry the page as requested selects governs; where it selects none, the entry the first other reading selects, in breadth-first order: the readings one operation away, then two, each step trying decode, merge, resolve and strip in that order.//news/1falls to a/news/entry only when no entry covers//news/1as requested, and/x//..%2Fnews/1falls to a/x/news/entry (two operations) before a/news/entry (three). An entry covers the spellings above only where no other entry of the licence covers the URL as requested, so the requested spelling can select a less restrictive entry. Beside a catch-all/entry,//premium/xand/premium%2Fxare governed by/, not by a narrower/premium/entry. Under a permissive/news/free/entry,/news/free/..%2Fpaid/xis governed by/news/free/, not by/news/, although a server that decodes and resolves serves it as/news/paid/x.
So //news/1, /news%2F1, /x/..%2Fnews/1, /x//..%2Fnews/1,
/news;x=1/1, /x/..;/news/1, /news%3Bx/1, /x/..%3B/news/1 and
/news%5C1 are refused under Disallow: /news/, and /news%2F1 is not
under Disallow: /news/1/. /news%2F..%2Fsports/1 is refused under
Disallow: /news/ as well, through /news/../sports/1, the path a proxy
that decodes %2F without resolving routes under /news/.
Some spellings are not matched. A raw reserved character in a rule’s query
is compared with the URL’s encoded one as distinct, so
Disallow: /go?u=https://x does not cover /go?u=https%3A%2F%2Fx (the
second example of RFC 9309 §2.2.2’s table). An empty query is kept, so
/news? is not under Disallow: /news$. A change of letter case is not
read: /NEWS/1, which a server on a case-insensitive file system or IIS
serves as /news/1, is not under Disallow: /news/. A double encoding is not
read: /x/..%252Fnews/1, which a server that decodes the path twice serves
as /news/1, is not under Disallow: /news/.
An anchored rule’s last part must end the path: Disallow: /*.pdf$ covers
/a.pdf/b.pdf. A rule ranks by the length of its form, so spelling a rule
longer does not make it more specific. Two spellings of one path tie, and
the tie is settled as for two identical rules: Allow wins an access-rule
tie, tied Content-Usage rules all apply, and two relative <content>
entries are read as one entry (below). The URL requested and the URLs
recorded (url, requested_url, reading.access_rule, the licence’s
content) are as written.
reading.crawl_delay is the selected group’s Crawl-delay, and delay is
what it did to this request. The delay binds in every policy mode, signed
or unsigned. value is the line as written and delay_ms that
value in milliseconds; a value that is not a non-negative decimal number
of seconds is listed in unreadable and ignored, and a group that states
none has no crawl_delay. honoured_ms is the delay this edge keeps:
delay_ms, or 60000 where the file states more, with capped true. Every
group naming CommonMeasureBot supplies the delay where the file has such a
group, and every * group otherwise, as for the access rules: RFC 9309
section 2.2.1 merges the records of all the groups that name the fetcher, and
the merged values keep the longest delay.
The time of the last request to each host is kept under
$COMMONMEASURE_HOME/crawl-delay/, one file per host with a lock file beside
it, so the delay holds across sessions, across restarts and across two MCP
servers on one edge: each request takes its turn under the lock, writing the
time it will be sent before it waits. Once the request has been answered, or
has failed, the file is moved forward to that moment where it holds an
earlier time, so the next turn is measured from no earlier than the request
reached the host. For requests made one after another, the name lookup, the
back-off check and signing that come between a turn and its request do not
shorten the gap the host sees. Two servers on one edge whose requests to a
host interleave inside the delay can still reach it less than the delay
apart: the second measures from the first’s turn when the first has not yet
been sent. That case is still open. The
file is named for the host, or, for a host too long for a file name, its
first 40 characters and a SHA-256 digest of the whole name.
delay.outcome is clear (the last request was longer ago than the delay),
waited (wait_ms slept through before the request),
refused (the wait did not fit budget_ms; nothing was requested and
next_at says when the host may next be asked) or unavailable (the turn
itself could not be kept, and nothing was requested rather than a delay being
dropped). A refusal names the file, the delay as written, the host and
next_at, in every mode. licence_first names the licence whose turn the
page waits behind (below); on a refusal decided before either turn was
taken, wait_ms is the licence’s wait and the page’s delay together,
next_at is when the host may next be asked, and where the licence was
not sent unavailable says why.
Response-driven back-off is recorded separately in declarations.backoff,
an ordered array covering pages, redirect hops, robots.txt, licence and
manifest probes. Each entry has target (the request URL, or the host when
waiting before a crawl-delay turn) and outcome: set, reset, waited,
refused or unavailable. A backoff object carries failures (the
consecutive failure count), status (the failure’s HTTP status), until
(the earliest permitted send), retry_after (a usable header supplied the
interval) and capped (that header exceeded one hour). Waits and refusals
also carry wait_ms and budget_ms; refusals and unavailable decisions
carry reason. Own edges keep the full failure details. status,
retry_after and capped describe the failure that set the end; a shorter
later failure does not replace them. http_date, when present, is the
uncapped HTTP-date that set the end. While another request holds a reservation,
reserved_until names its timeout separately from the failure’s until.
An own edge’s refusal in that time says another request to the host is
under way and holds its turn until reserved_until; it names the back-off’s
end and status only while that period lasts. Once another request’s failure
runs to or past reserved_until, the record holds no separate timeout:
reserved_until is absent, and a waiter reads and waits out the back-off
alone.
A reset retains the last failure’s status and end time with failures: 0, so a successful concurrent request
cannot revoke a wait another answer has already imposed.
A 429 or 503 with a usable Retry-After honours delta-seconds or HTTP-date,
up to one hour. Other 5xx answers, and 429 or 503 without a usable header,
impose 10 seconds, doubling for consecutive failures up to 15 minutes.
Any non-5xx answer other than 429 resets the count. No failed request is
retried automatically. The next request waits within the fetch’s remaining
sleep and whole-call budgets, or is refused naming back-off. Own edges name
its end; hosted disclosures are limited as described below.
A later answer cannot shorten a wait already imposed. A failed answer adds
one to the count only when it arrives at or after the end it found. Failures
inside that period keep the count and may extend the end.
After back-off ends, the first sender reserves the host under its lock until
that request’s timeout. Its answer clears or renews the reservation; other
callers wait for the answer within their own budgets. A request that ends
without an HTTP answer (not sent after all, or a transport failure such as a
refused or reset connection, TLS failure or timeout) releases its own
reservation: until returns to the failure’s end, the count is unchanged and
back-off is not extended. A response whose status line arrived but whose
headers or body could not be read whole (for example a truncated body, an
unsupported content coding or a body over the size limit) counts as no
answer: its status and Retry-After are not recorded, and the reservation
is released. A release never touches a reservation a later sender has
taken. A process that stops without an answer leaves a
reservation that expires at the timeout, as does a release that cannot
update the record. An answer this edge cannot record releases the
reservation only when the answer is not itself a failure; a 429 or 5xx it
cannot record keeps the reservation to its timeout. This reservation does not
add a failure or resend a failed request.
Back-off is kept in <host>.backoff.json beside the crawl-delay turn,
under the same host lock. Every MCP process using the home shares it, and
it survives restart. An unreadable back-off record or a failed update is
explicitly unavailable. An unreadable record refuses before sending. A
read-only store permits a healthy host with no active failure record: its
successful answer takes no lock and writes nothing. When an answer needs an
update and that update fails, the failure belongs to the edge; it is never
cached as an unreachable robots.txt. A page’s crossing keeps the answer’s
http_status beside failure. After repair the next crossing asks again.
The remedy retains the storage error’s cause and names that host’s back-off
file where the record cannot be read, or the back-off directory where it
cannot be created, locked, written or removed. Two lock faults have their
own remedies: a lock another process on the edge held for the whole wait is
named as held by it, and a lock file that exists and cannot be opened is
named, to be made readable and writable by this user or removed while no
process holds it. Crawl-delay recovery does not clear back-off. Interrupted
back-off writes are swept with crawl-delay temporaries; expired zero-count
records are removed when a reset has no remaining wait, or during a later
sweep under a host lock already held for an update. Healthy sends do not take
a lock just to prune.
The later of the crawl-delay turn and back-off controls the send. Where
both apply, back-off is waited before reserving the crawl-delay turn:
concurrent callers must not reserve turns in the past and send together
when back-off ends. The existing robots.delay ruling keeps its shape,
and delay_ms remains only the honoured crawl delay. A refusal before the
turn writes no robots.delay; its evidence is only in declarations.backoff.
A probe refused by back-off records cache: "not_asked", with no
fetched_at or expires_at on its robots reading. The crawl-delay turn is taken
after any back-off wait, against the remaining budget. A further check
before transport catches back-off learned while taking that turn.
robots.txt remains exempt from crawl-delay, but observes back-off. It
takes no turn, but on a host this call already paces (another origin of the
same host, such as the https origin after an http redirect, or another
port), its answer moves the host’s next turn later, as any answer does.
budget_ms is what the context_fetch may still spend asleep when the turn
is taken. On an edge served over stdio one call may spend 60 seconds asleep
in total, the same bound as the longest delay this edge keeps, so a first
crossing to a host is never refused by the delay it has just read. On a
hosted edge, served over streamable HTTP, one call may spend 20 seconds, and
a wait beyond that is refused with the transport named (below). Only sleeping
is charged to the budget: name lookups, connections, signing and responses
are bounded by the request timeouts and by the whole-call ceiling instead.
The requests of one crossing take their turns in this order. robots.txt
takes none, because the delay cannot be read without it. The access rule is
ruled from it before anything else is asked of the host, so a crossing
refused on a Disallow or an unreachable file, in any mode, takes no turn
and asks for nothing further: not the page, not the licence robots.txt names or the one the
page last named (a licence robots.txt names is recorded not_asked), and
not the Content Telemetry manifest.
A page is never admitted under a licence this edge has not read: a
reporting demand binds in every policy mode, and content whose reporting and
licence requirements are not respected is not taken. So a licence with no current reading in the cache is read before
the page: the one robots.txt names, and, on a host that states a delay,
the one the page’s Link header named when it was last fetched. Each takes
the host’s next turn, and the page waits for its own turn after them, so a
first crossing to a host that states a delay and names a licence spends two
delays; that is the cost of reading the terms. Whether licence-then-page
fits what the call may still spend asleep is decided before either turn is
taken, from the host’s pending wait plus one delay per licence: where it
does not fit, the crossing is refused before any request is sent to the
host, naming the licence and the delay, with the licence recorded
not_asked and delay.licence_first set. The licence is never read only
for the page to be refused after it. A licence already read and still
current takes no turn, and the page’s turn is the only one. A licence
reached only through the page’s response, on a first fetch, is read after
the page with whatever the call has left; where that does not fit it is
unread, the bytes are withheld, and the next crossing reads it first.
The page’s own turn is taken last, once the licences have been read, any
allowance consulted and what the terms say ruled on, so a crossing its
terms refuse waits for nothing and leaves the host’s turn to the next
request. Where the host states a delay, no licence is to be read first and
its manifest is not in the cache, the manifest probe is asked before the
page, but only if the host is clear: it takes a turn that costs nothing or
is not sent. Where a licence is to be read first, the licence has the
host’s next turn and the manifest waits for a later crossing: it carries no
demand. Every redirect hop is ruled
and takes its own host’s turn in the same order, a redirect from a licence
or manifest probe included. A probe’s redirect takes its turn from what the
probe was given, so what the probe keeps back for the requests after it is
kept back from the redirect too: a licence read before the page keeps the
page’s turn, and the manifest asked before the page, which takes only a
turn that costs nothing, follows its redirect only where the target’s host
is clear. A probe’s redirect whose turn does not fit is not requested: the
probe’s record says the redirect was declined and why, and is kept for five
minutes, as a redirect to a host in back-off is. A publisher’s log cannot
tell a probe from the page fetch, which is why the probes are paced; a
manifest probe whose wait does not fit is not sent and is recorded as
not_asked rather than refusing the crossing. A licence another server’s
turn crowds out between the check and the probe is not sent either, and
the crossing is refused with unavailable saying why.
A licence robots.txt or the page names that is unread refuses the
crossing in every policy mode: its terms exist and are unknown, and they
may carry a reporting demand. The failure is recorded as unavailable,
remembered for five minutes and asked again after it. A licence that is
missing (404 or 410) does not refuse: the crossing proceeds in every mode
as though no licence terms were declared, and the record and the tool
result carry the gap naming it. Adoption of licensing standards is
uneven, and a publisher’s broken link should not make its site
unreachable.
On a paced host the manifest is asked before the page rather than after it,
which is an exception to discovery never delaying a crossing. After the page,
the probe would need a whole delay from what the page left, so a host stating
more than half the budget would never have its manifest resolved and would
lose its owner identity and telemetry endpoint on every crossing. A manifest
that was not asked is recorded with cache: "not_asked" and held until the
host may next be asked (expires_at is the delay after the attempt), so the
next crossing after the delay asks it at its first free turn, and the record
is not rewritten on every crossing in between.
The host timeouts these bounds sit under, measured in Claude Code 2.1.278:
- stdio: a tool call is ended at
MCP_TOOL_TIMEOUT, 300000 ms by default, a hard wall-clock limit per call that progress notifications do not extend, with a per-servertimeoutin the host’s configuration overriding it. - streamable HTTP and SSE: a request is aborted at 60000 ms by default. A
configured timeout is raised to at least 60000 ms, and the floor is 30000 ms
(
MCP_TIMEOUT).
Without a whole-call bound the worst case was about 310 seconds: six requests
along a five-hop redirect chain at the 30-second client timeout (180 s), two
5-second probes a hop (60 s), the two manifest probes (10 s) and 60 seconds of
sleeping. That exceeds the stdio default. One context_fetch therefore keeps
a whole-call time limit: 240 seconds over stdio, and 50 seconds on a hosted
edge, under the 60-second streamable-HTTP default. It is checked before every turn, hop and probe: past it no
further request is sent, a probe is recorded not_asked and a hop is
refused, each saying the call reached its time limit, and what may still be
slept is never more than what is left of it. No request, page, hop or
probe, is given longer than what is left of the limit, so a request in
flight does not carry the call past it; name resolution is the one step no
timeout here bounds. The hosted budget of 20 seconds of sleeping sits inside
the 50. The hosted limit caps how far a hosted call follows a slow redirect
chain: a chain that has not answered inside 50 seconds is refused naming
the limit, where an edge served over stdio would have followed it, and the
caller reads a refusal rather than a timeout. The wait parks the thread
that serves the call and cannot be cancelled; a host configured with a
shorter timeout than these sees the call time out rather than the
refusal.
A turn that cannot be kept is fail-closed: where the store’s directory cannot
be created, the lock cannot be taken or the record cannot be written, the
outcome is unavailable, nothing is requested, and the reason names the
remedy, with the directory on an edge served over stdio. A record that cannot be read, or that is dated
beyond the longest turn plus the longest wait because the clock moved
backwards, is discarded: the request owes a full delay, waits it out, rewrites
the record, and recovered says which record was discarded and why. Nothing
prunes the directory: it holds one small file and one lock file per host ever
fetched, an operator clears one host by removing its file and all of them by
removing the directory, and each host’s interrupted writes are cleared when it
next takes a turn.
An edge serving several tenants from one home paces them all as one fetcher,
because one identity is what the publisher sees. A refusal served over that
transport carries no next_at and no wait_ms, and says the session is
served over HTTP; its unavailable and recovered text names the store’s
files by name alone, under the directory’s name crawl-delay where a cause
gives a path, and no recorded instant, because the tenant can act on neither
the operator’s file system nor another tenant’s fetch. The same holds for a
back-off unavailable event’s reason, whose remedy there is the
operator’s: the store is kept on the edge that serves the session, and its
operator can make it writable or clear it. A waited
ruling still carries wait_ms there, and a tenant can derive from it when
another tenant last asked the host; the call’s own elapsed time gives the
same figure, so withholding it would hide nothing.
The tools name the files they report the same way over that transport.
recorded_in, and context_status’s evidence, policy.source and
credentials.path, name their files relative to the operator home, as in
sessions/<id>.ndjson, and a refusal names “the operator’s policy” with no
path. The transport then rewrites the operator home in everything it sends:
every string in a JSON body, which covers tool results, tool errors,
JSON-RPC errors and the HTTP error bodies including the 401, and every
response header, including the WWW-Authenticate challenge. A body that is
not JSON is not sent; the answer is a 500. The home is matched as
COMMONMEASURE_HOME gives it, made absolute, and as the file system
resolves it, with or without a trailing separator. An occurrence of either
form that is not preceded by a name character (a letter, digit, _, - or
.) or a separator, and not followed by a name character, becomes the path
relative to the home, as allowance/ledger.ndjson; the home alone becomes
.. Text that is not a path under the home can match: with the home
/srv/cm, ~/srv/cm/x becomes ~x, ${HOME}/srv/cm/x becomes
${HOME}x, backup@host:/srv/cm/x becomes backup@host:x and
/srv/cm/../etc becomes ../etc. A string the tenant supplied and the
edge quotes is rewritten like any other, so it can confirm a guessed home;
it cannot read one. For that reason the edge does not quote a tool name, a
JSON-RPC method, an MCP-Protocol-Version it does not serve, or an
Origin header that is not an origin; the Origin check runs before the
token is read. JSON-RPC requires the request’s id in the answer, and a
string id is rewritten like any other string, so an id naming a path
under the home comes back relative to it. Before
authentication the edge quotes nothing else a caller sends: a foreign
Origin is quoted only in canonical form (scheme, host and port, with no
path), and a token refused before its signature is checked is refused by
the check that failed, naming what the edge expects (EdDSA, the issuer
pinned at enrolment, a key the issuer publishes) and no value the token
carries.
A tool result’s payload is JSON text inside a string; the transport parses
it and applies the rule to its strings, so a path after a newline is
matched. In a result that is not a tool error (isError is false), the
fields that carry what a supplier sent, at exactly these pointers, are
served as received: a fetch’s content, url and next and its
declarations.robots.requested_url, declarations.robots.robots_url and
declarations.robots.final_url, and a search’s results[].url,
results[].title and results[].text (SUPPLIER_FIELDS in
crates/commonmeasure-harness/src/mcp.rs); a search’s refusals[] carry a
result’s position and the reason, never its URL. The same
pointers under a fetched file’s structuredContent, and its embedded
resource’s blob and uri in the answer (SUPPLIER_BLOCK_FIELDS), are
served as received too: base64 can spell a path by chance, and the served
bytes must still hash to the recorded content_hash.
declarations.robots.explanation quotes the asked URL in prose and is
rewritten. A page that quotes the operator home reaches the tenant as the publisher sent it,
so its text still matches content_hash, and a URL still names the page it
named. A file:// URL is an identifier and a supplier field, so a document
in an internal corpus kept under the home is served by its full file://
URL, which names the home; an operator who does not want that keeps the
corpus outside the home. Payload text that does not parse is rewritten as
text. The hosted service is not documented or tested on Windows.
The service’s standard error names the token file and a session that could
not be opened by their full paths. An allowance ledger fault is not printed
there; the ledger is always allowance/ledger.ndjson under the home. An
edge served over stdio names each file by its full path.
For response-driven back-off on a hosted edge, the tenant’s refusal text and
declarations.backoff event say only that the host is in back-off. They
withhold status, failures, wait_ms and any until derived from a
response time, including delta-seconds Retry-After and a capped HTTP-date.
An uncapped Retry-After HTTP-date that set the end may be named as until,
because it is the host’s own statement. The tenant’s backoff object is
otherwise empty; budget_ms remains that call’s own budget. Two channels
remain: a reset event on the tenant’s own answer that is not a failure
tells it that a request to the host, perhaps another tenant’s, had failed,
and on a paced host a waited event’s budget_ms less
robots.delay.budget_ms gives the back-off wait that wait_ms withholds,
and with it the until withheld above, as the call’s own elapsed time does.
Neither
reveals the other request’s status, the failure count or a response time.
Own edges keep the full record.
Each redirect hop is evaluated against the robots.txt at its own origin
before the hop is requested: a hop its own file disallows is refused, in
every mode, before that hop is asked for, and being a redirect exempts
nothing.
redirects holds the evaluation of every earlier hop in request order, and
robots is the last hop evaluated: the URL that answered, or the hop that
was refused. A shortener’s Disallow is therefore recorded against the
shortener’s URL and never against the page it pointed at, and a destination
URL the agent names directly is judged by its own file with redirects
empty. statements and licences are those of the origin that answered.
Where a hop is refused by host policy before its file is read, robots
stays the last hop that was evaluated and its requested_url says which.
A redirect to a URL whose scheme is not http or https is refused in
every mode before anything is read for it: this edge requests nothing else.
The crossing is crossing_refused, url is the target whole, and
refusal says the scheme is not http or https.
A disallowed ai-input statement, the use a mediated fetch makes of a page,
is one of the source’s terms, and it refuses the crossing in every policy
mode on every edge, enrolled or not (fail policy §6):
before the request when the statement was known then, and otherwise after
it, in which case the bytes were fetched and are withheld from context, their
hash on the refused crossing and grounded false. A licence whose AI-input
permission is conditional on a payment, or on a token from a licence server,
is a term this edge cannot meet without a settlement rail, and it refuses the
crossing the same way, with the term named: the edge pays or does not fetch.
The mode governs the operator’s own policy, not these terms.
A payment term is unmet unless the licence says it is free. RSL 1.0 §3.7
defines eight type values and no default for a <payment> that names
none, and reads an omitted <payment> as free. A term of type free or
attribution, or with no type and no <amount>, <standard>, <custom>
or <accepts>, needs nothing a rail would give. Any other term refuses: the
six monetary types, a value §3.7 does not define (one a later revision adds
included), a case variant of a defined value (Use; the values are given
as written and XML attribute values are case-sensitive), and a term with no
type that states an amount, points at terms or lists the payment methods it
accepts. The agent is told a type
§3.7 does not define as “a payment type this edge does not recognise”,
never the value, which is the source’s text; the record keeps it whole
under licences[].terms.payment.kind. One rule decides this for the
refusal, the price the allowance gate reserves and the choice among offers
below (RslPayment::needs_settlement in
crates/commonmeasure-harness/src/declarations.rs). A licence with more
than one <payment> needs settlement if any one does.
licences[].terms.payment is the one ruled: the first that needs
settlement, else the first (RslLicence::payment). Where the offer has more
than one, licences[].terms.payments lists every one in document order.
A licence’s usage <permits> and <prohibits> are each read as every
token of every element of that kind it writes: §3.5 allows one, and a
document that writes more states each as a term, so none is dropped and
their order does not change the ruling.
A licence with no usage <permits> is silent on usage. A licence silent on
usage is a grant only where no licence of the same <content> names AI
input in a usage <permits> or <prohibits> (ai-input, ai-all or
all). §3.5 restricts usage only where a <permits> of that type exists,
so such a licence covers every usage; §3.1.1 gives the more specific
declaration precedence and reads licences conservatively, so where a sibling
names AI input, that sibling’s permits, prohibits and payment decide as
written and the silent licence is no offer. A sibling that names only other
usages (search, ai-train) leaves it a grant. A silent licence that is a
grant has its payment term and its entry’s licence server ruled on as those
of a licence that permits AI input by name, and its AI-input statement reads
licence <n> lists no usage it permits, so covers usage ai-input.
A licence with a <permits> or <prohibits> of type user or geo
authorises nothing: the edge cannot show which class of user it acts for or
where. It is no offer. Where it permits a usage by name it permits it only
to that class, so for this edge it permits none and its statement for that
usage is a Disallow; a priced offer restricted this way therefore refuses
and is never read as no term. A restricted licence silent on usage that
asks for payment gives a Disallow for AI input as well: inside the class
the price binds and this edge cannot pay, and outside it nothing grants the
page. A restricted licence silent on usage and free adds no statement. Its
prohibitions bind as any licence’s do. The record lists the positions of
restricted licences, counted from 1, in licences[].terms.restricted.
Where no licence authorises AI input, licences[].terms.payment (and
payments, where it has more than one) is the payment term of the first
restricted licence that asks for payment, so the price is on the record
although no offer is; the agent is told the Disallow, not the price.
Several <license> elements in one <content> are offers (§3.4), and the
order they are written in does not change what they mean (§3.1.1). The edge
may take any offer whose terms it can meet, so among the licences that
authorise AI input it takes one whose payment term needs no settlement, then
the first written. Its position, counted from 1, is
licences[].terms.offer, and its payment term is
licences[].terms.payment. Where every offer needs settlement, the first is
the one the refusal names. A free offer beside a priced one is therefore
delivered whichever comes first.
A prohibition of AI input takes precedence over a permit in another licence
of the entry that governs the page (§3.1.1). Where any of its licences
prohibits AI input, no offer is taken, whatever another licence permits by
name or by silence, free or priced, and in either document order, and the
AI-input statement is a Disallow that names the prohibiting licence: licence <n> prohibits usage ai-input, which takes precedence over the permit of licence <m> where another licence permits AI input by name, and no licence permits usage ai-input (licence <n>) otherwise. A licence prohibits AI input
where a usage <prohibits> covers it and its own usage <permits> does not
name AI input more specifically: within one licence the more specific term
stands, so permits ai-input beside prohibits all permits, and prohibits ai-input or prohibits ai-all beside permits ai-input prohibits. A
licence restricted to a class of user or a region that prohibits AI input
counts, since the edge cannot show it is outside the class or the region.
The same holds for entries read as one (below).
The choice among offers does not read reporting demands. Of two free offers
that differ only in a reporting demand this edge cannot meet on its route,
the first written is taken, so the document order decides whether the page
is delivered: with the demanding offer first the crossing is refused on the
demand, and with it second the page is delivered under the other offer.
This order dependence is recorded and kept. It applies within one
<content> and across entries read as one, which includes two entries of
unequal length neither of which lies within the other (/xy and /x*z for
/xyz) and an absolute scope read with a relative entry of the same path.
Of the <content> entries that match the page, the narrowest govern, as
§3.1.1 gives the more specific declaration precedence: each matching entry
whose scope strictly contains another matching entry’s scope is set aside,
and the entries left govern together. So /ab governs alone over /a*b for
/abc, /news/ over /new*s/ for /news/1, /p over /*p for /p and
over /* for /page, and /blog/a.pdf$ over /*.pdf$. The length of a
pattern plays no part. Entries of the same scope, written twice, in two
spellings or with and without a trailing * (/news/ and /%6Eews/, /
and /*, /p and /p*), govern together, and so do scopes neither of
which lies within the other (/xy and /x*z for /xyz, /a*/x and
/*b*x, /blog/ and /*.pdf$ for /blog/a.pdf). Scope A lies within
scope B when B matches A’s most general target: A’s form with each *, and
the open end of a pattern with no final $, read as an octet no pattern can
name.
An absolute scope the page is under takes part by the path and query of its
request target in the matching form, compared as the literal prefix it is.
So https://host/ and / are one scope and govern together, https://host/
and /* govern together, https://host/a lies within /* and within /,
and /a lies within https://host/. Of two absolute scopes of one path in
different spellings, one that does not match the page as written is set
aside beside one that does, and then the shorter written beside the longer.
An empty url, and an absolute scope that does not parse, which is matched
as written, govern only where no other entry matches: the scope that does
not parse over the empty url, the longer written over the shorter, and two
of one kind and written length together.
Entries that govern together are read as one entry, since §3.1 does not
let document order change what a document means: its licences are theirs
in document order, numbered from 1 across them for offer and
restricted, its licence server is the first any of them names, and the
licence’s content is the first’s url. A free or attribution offer
permitting AI input by name beside a priced offer in governing overlapping
entries is taken in either document order, provided its other terms are met.
For example, /blog/ offering AI input free beside /*.pdf$ pricing AI
input makes /blog/a.pdf available free in either order. A prohibition of
AI input in either governing entry still refuses it. A prohibition in one
and a licence silent on usage in the other are siblings, so the prohibition
refuses in either order, and a prohibition in one refuses beside a permit by
name in the other (above). So where /blog/ permits AI input by name and
/*.pdf$ prohibits it, /blog/a.pdf is refused and /blog/a.html is
delivered. A narrower entry that permits beside a broader one that prohibits
governs alone, so /p permitting beside /* prohibiting delivers /page.
Selection compares the matching entries with each other, so its cost grows
with the square of their number and with the length of their patterns. It is
bounded by the work it does, counted before any test is made: every
matching entry’s form read once, and for each ordered pair of distinct forms
the octets one test compares (the inner scope’s general target and the outer
scope’s form) plus 64 for the test itself. Every entry that matches the
reading counts toward the budget, relative and absolute alike, since no
entry outranks another before the tests are made; entries that do not match
the page, an empty url and an absolute scope that does not parse are not
compared and count nothing. Where the work would exceed 64,000,000, nothing
is selected and the licence is unread for the page: unavailable says
that choosing among the <content> entries that match this page takes more
work than this edge does for one page, content and terms are absent, and
the crossing is refused in every mode, as for any unread licence. No entry
is selected in its place, so a prohibition or price among those entries is
never passed over. 256 matching entries with patterns of up to 64 octets are
within the budget. Where entries match and the tests leave none governing,
which happens only where a containment could not be decided, the licence is
unread in the same way, and unavailable says that <content> entries
match this page and none of them could be ruled the narrowest. A reading past
the budget ends the search: a later reading is not tried, and an earlier one
that selected an entry keeps it. One crossing selects a licence body for a
page once, across its redirect hops and the Link licence; a body that
changed within the call is selected again. The budget counts the comparisons
among matching entries only: matching each entry against each reading of
the page is not counted, and its cost grows with the number of entries, the
length of the page’s path and the number of its readings.
A <content> entry’s licence server binds whatever its offers (§3.7: a
server binds regardless of the payment type), so the crossing is refused on
it wherever no Disallow has refused it first, an entry with no <license>
included (§3.3 requires at least one). An entry with no <license> and no
server makes no offer: no offer is recorded and the crossing proceeds as
the other statements leave it.
Every License: line in the selected robots.txt group, or the global
lines where the group has none (§4.4.2), is read, and each is an entry in
licences, in the order written, with a URL named twice read once. Each is
a separate document, and §4.9 binds the most restrictive combination of
their terms: the payment, licence-server and reporting terms of every one
are ruled on, so a free licence and a priced one refuse on the priced one.
A licence the page’s Link header names is more specific (§4.9) and, where
it was read, governs those terms in place of the robots.txt licences;
licence.reference on the crossing is the first governing licence, as
before. On a host that states a delay each licence with no current reading
takes a turn before the page, and where a licence before it was not sent the
later ones are recorded not_asked and the crossing is refused on them.
A licence’s reporting demands are ruled on where the source’s own statements
govern, whatever the combined AI-input preference. A Content-Signal in
robots.txt or a Content-Usage header that disallows AI input beside a
licence that permits it does not set the demand aside: the Disallow refuses
the crossing, and the demand is ruled on as well and recorded in
declarations.reporting.
Which demands apply follows RSL 1.0 §3.12, under which a demand binds activity
the enclosing licence authorises. A licence authorises AI input where its
<permits type="usage"> covers it, or where it has no usage <permits> at
all and no licence of the entry names AI input, because §3.5 restricts usage
only where a <permits> of that type exists (§3.4 and the grammar make
every child of <license> optional); in both cases no usage <prohibits>
may cover it, and a licence restricted by user class or region authorises
nothing (above). The demands of the offer the
crossing is taken under are ruled on, and so are its payment term and its
entry’s licence server: one choice of offer, above, serves all three. Where
no licence in the governing entry authorises AI input, every demand in the
entry is ruled on, whatever else refuses the crossing: taking the page
outside the licence does not excuse the fetcher from the report its owner
asks for. Where robots.txt names several licences, the demands of each are
ruled on.
The operator’s recorded agreement with a source is a reporting demand too.
Where the source policy’s terms entry for the page’s host sets
requires_reporting (source policy §Terms), the
crossing carries a telemetry demand attributed to the agreement, ruled as a
licence’s telemetry demand is and in every policy mode (owner decision, 30
September 2026: the source agreed to it, so it is a source term). The
agreement names no endpoint, so the hub the edge is enrolled with is its one
route (below), and it names no profile or level, so neither is checked.
Where the receiver is not that hub, the reason says that the agreement names
no endpoint, so only the hub this edge is enrolled with is a route. Its
refusal tells the agent the operator’s own reference and nothing the
source chose: “The operator’s agreement with the source (<reference>)
requires reporting of each use and the demand cannot be met: …”, or, where
consent alone is missing, “The source needs reporting: the operator’s
agreement with it (<reference>) requires reporting of each use, and …”.
An agreement that also names access_context is unmet in every scope while
the relay withholds such crossings (below). Tested in
crates/commonmeasure-cli/tests/mediated_e2e.rs
reporting_demand::an_agreement_requiring_reporting_with_no_route_is_refused_in_every_mode,
an_agreement_requiring_reporting_is_met_through_the_hub_in_every_mode and
an_agreement_not_requiring_reporting_is_unchanged_in_every_mode.
A licence’s telemetry reporting demand is met when its profile is the Content
Telemetry binding this runtime speaks, its conformance level is one this
runtime emits (retrieval or grounding), and the session can deliver: the
relay would project a crossing of the page, so its URL is not a local or
private address and is under no prefix in record_internal_prefixes, either
the session’s or that of policy.json as it stands at the ruling (telemetry
projection: the relay projects neither; a private
address is reached only under allow_private_hosts or a prefix named in
record_internal_prefixes, and under neither on a hosted edge in service
mode, which holds the private-address floor), policy.json as it stands at
the ruling loads, $COMMONMEASURE_HOME/relay.json is
one the relay loads and names a receiver, that receiver is a route to the
licence’s endpoint (below), automatic delivery is in force, no
operator terms hold the session back (below), and the operator has agreed to
reporting (§Reporting consent). A
relay.json the relay refuses leaves the demand unmet with the load
error as the reason, because the relay sends nothing under it. relay.json
is read at each ruling, not once when the MCP server starts, so a receiver
removed during a long-lived session (Claude
Desktop keeps its server for the life of the app) leaves the next demand
unmet. A policy.json that does not load at the ruling leaves it unmet with
the load error as the reason, as the relay refuses to run on it. The scope’s
telemetry clearance does not decide the demand: a crossing admitted under
consent leaves under consent in a scope that clears nothing, or that sets
allow_telemetry_egress: false, and a cleared scope does not stand in for
consent that was not given.
The receiver is a route to the licence’s endpoint in two cases, checked in
this order and recorded as route:
licence_endpoint: the relay posts to the endpoint the licence’s<reporting type="telemetry">element names. The URL posted to, the receiver followed by/events, must equal the endpoint after the parse two receivers are compared by (telemetry projection §One receiver); path and query are compared as sent, so a receiver copied from the endpoint does not match.hub: the edge is enrolled ($COMMONMEASURE_HOME/enrolment.json, the recordconnectwrites) and the receiver has the origin of the hub it is enrolled with, the same origin testconnectapplies before it gives a receiver the hub’s ingest key. The hub delivers each event on to the endpoints its source declares (onward delivery). A revoked key is no route, since the hub refuses its events.hubnames the route, not the test that found it.
Any other receiver leaves the demand unmet in every mode, whatever the
operator agreed to: the events would go somewhere the licence did not name.
The reason says the receiver is neither the hub this edge is enrolled with
nor the licence’s endpoint, names the endpoint (the source’s text, shortened
for the agent as other source URLs are), says that the relay posts to the
receiver followed by /events, and says whether the edge is enrolled and
with which hub by origin, or that its key is revoked, or why
enrolment.json did not read. A licence that names no endpoint can be met
through the hub alone. enrolment.json is read at each ruling, like
relay.json, so disconnect refuses the next demanding crossing of a
running server. consent_needed is never set for this reason, since
agreeing would admit nothing. Tested in
crates/commonmeasure-cli/tests/mediated_e2e.rs
reporting_demand::an_unenrolled_edge_relaying_to_a_receiver_that_does_not_forward_is_refused,
an_enrolled_edge_relaying_to_its_hub_is_admitted_through_the_hub,
a_receiver_posting_to_the_licence_endpoint_is_admitted_through_it and
a_receiver_differing_from_the_licence_endpoint_only_in_path_is_refused, and
in crates/commonmeasure-harness/src/mcp.rs
a_reporting_demand_is_met_only_through_the_enrolled_hub_or_the_licence_endpoint.
The relay withholds each crossing it clears whose host falls under operator
terms naming institution identifiers (terms[].access_context), because the
event-batch delivery format carries no session data to state them
(telemetry projection); the other crossings of
the session leave as their clearance says. A demand is therefore unmet, in
every scope, when the top-level terms of policy.json as it stands at the
ruling declare such terms for the page’s host. A scope carries no terms, so
the session’s scope does not change the answer. The ruling
reads the relay’s own predicate (commonmeasure_harness::egress), and since
the relay holds a crossing by its own host alone, no other crossing of the
session, recorded before the ruling or after it, admitted or refused, in the
same process or another, changes what the relay does with the crossing ruled
on. Terms added to policy.json for the page’s host after an admission still
hold that crossing at the relay, which reads the policy at projection, and
the relay’s report counts it. A session log that does not read leaves the
demand unmet, naming the log and the line, because the relay skips such a log
whole and nothing of the crossing would leave; a log not yet written is not a
fault. The ruling reads the log as the relay will (§Reading a log): a final
line cut short does not leave the demand unmet, because the relay marks it
before it reads and this crossing’s own record marks it first. The ruling
holds one line in memory at a time and remembers the file and how far it has
read, so each ruling reads only what was appended since the last; a log
replaced or cut shorter is read again from its start. Only the log’s
readability is read, not its other crossings. A log damaged after the ruling
is skipped by the relay like any other, and the crossing goes unreported. Automatic delivery
means the events leave without anyone typing a command — the session-end relay
(telemetry projection §Relay at session
end), or the hosted service’s
or the background relay’s interval (telemetry projection §Relay on an
interval). The marker file
$COMMONMEASURE_HOME/relay/manual switches it off, and a demand is then unmet
however the rest is configured; the reason names the marker, so the operator
reads what to remove. A profile this runtime does not recognise is an unmet
demand.
Which sessions have automatic delivery is read from the session itself and
the home’s own state, never from host files under $HOME:
- A running hosted service on the home: automatic for every session in it,
whatever its host word or client, stdio sessions included, because the
service’s interval relay reads every session log in the home. “Running”
means a process holds the home’s lock
hosted-service.locknow (delivery::service_state, the probedoctorandstatususe); a service that is configured but stopped holds nothing and does not count. A lock file the probe cannot open does not count either: whether a service holds it is unknown,doctorandstatussay so, and the session is treated as having no automatic delivery. The interval relay skips its run whilerelay/manualis present and says so in the journal. - A running background relay on the home (
commonmeasure relay --every <seconds>): automatic for every session in it, whatever its host word or client, for the same reason: each of its runs reads every session log in the home. “Running” means a process holdsrelay-loop.lockin the home now (delivery::relay_loop_running, read by the same probe as the service’s lock); a lock nobody holds does not count, and a lock file the probe cannot open does not count either and reads as “cannot be read” indoctorandstatus. It skips its runs whilerelay/manualis present, as the service does. This is how a Claude Desktop session meets the demand. - A hosted session under
hosted service: automatic for the same reason.hosted serveruns no interval relay and takes no lock, so its sessions are not automatic. --host claude-codewith the clientclaude-code: automatic. Claude Code is the one host whose hook table registers a session-end event (CLAUDE_HOOKSincrates/commonmeasure-harness/src/registration.rs); the Cursor and Copilot CLI tables register none, and Codex, Pi, Claude Desktop and VS Code have no hook that runs at a session end.SESSION_END_HOSTSincrates/commonmeasure-harness/src/delivery.rsis held to those tables by a test.- Every other
--hostword: unmet, with the reason “no automatic delivery; this host (<host>) sends no session-end event and no background relay (commonmeasure relay --every) holds this home”. - Under
--host claude-code, any client name butclaude-code: unmet, and the reason names the client.--hostdefaults toclaude-code, so the host word alone does not identify Claude Code: Goose has noinstalland is registered by a hand-written extension with no--host(host-integration.md§6), and any client not yet captured arrives the same way. The check is an allowlist because the two failure modes are not symmetric: a Claude Code build that announced another name would be refused visibly and fixed by one string, where a client missing from a blocklist would take content whose report nothing relays. The name is the one every recorded Claude Code session announced,{"name": "claude-code", "title": "Claude Code"}, Claude Code 2.1.270 to 2.1.278. Names known to belong to other hosts (codex-mcp-client,claude-ai,local-agent-mode-*,cursor-vscode) only make the reason more precise (“is not Claude Code”). A session that sent noclientInfotrusts the host word; MCP clients must send it, so that arises only in tests.
Whether Claude Code’s SessionEnd hook is actually installed is doctor’s
check (telemetry projection §Relay at session end), not the ruling’s: reading it at each licence
ruling would make the outcome depend on the machine’s host files. An
operator on Codex, Cursor or another host without a session-end event can
connect through a hosted endpoint where the host supports one (the service
serves claude-connector, chatgpt, m365-copilot and
copilot-cloud-agent, behind OAuth), run the background relay on the home
the stdio server uses (commonmeasure service install relay on macOS, or
commonmeasure relay --every <seconds> under another service manager), or
run commonmeasure hosted service on that home, which needs it enrolled and
under managed policy. While either holds its lock, that home’s stdio
sessions count. Otherwise a source whose licence demands usage reporting is
refused on that host. Tested in
crates/commonmeasure-cli/tests/mediated_e2e.rs
reporting_demand::a_host_that_sends_no_session_end_event_leaves_a_telemetry_demand_unmet,
a_known_other_client_under_the_default_host_word_leaves_the_demand_unmet,
an_unknown_client_under_the_default_host_word_leaves_the_demand_unmet,
a_stdio_session_on_a_home_a_running_service_holds_meets_the_demand and
a_claude_desktop_session_is_admitted_and_delivered_only_while_the_background_relay_runs,
and in crates/commonmeasure-harness/src/mcp.rs
a_hosted_session_meets_a_reporting_demand_only_under_the_interval_relay.
A <reporting> element of another type (provenance or audit) is a demand
this runtime cannot meet, since it reports telemetry only; it is unmet
whatever the scope clears, and the reason names the licence, the type and the
profile. An element that states no type is read the same way: RSL 1.0 §3.12
lists the types a client must satisfy and states no default for an element
that names none, so this runtime does not read a typeless element as the
telemetry one it can meet. The record shows the type as unstated.
An unmet reporting demand refuses the crossing in every policy mode, whether
the demand was known before the request or only after the bytes arrived, in
which case they are withheld from context with their hash on the refused
crossing. The mode the
operator set is not the source’s consent, and RSL 1.0 §3.12 says an activity
whose applicable demands are not satisfied is unlicensed. The operator’s
choice is whether to report, which the marker expresses, not whether to
decline the duty and still read. The other source terms above bind in every
mode in the same way; the operator’s host policy on a host a robots.txt
redirected to keeps the mode discipline. Tested in crates/commonmeasure-cli/tests/mediated_e2e.rs
reporting_demand::a_disallow_in_robots_does_not_excuse_an_unmet_reporting_demand,
a_content_usage_disallow_does_not_excuse_an_unmet_reporting_demand,
a_licence_without_permits_still_binds_its_reporting_demand and
a_met_demand_beside_a_disallow_is_refused_on_the_disallow_in_observe.
The telemetry ruling is on the record as declarations.reporting: where the
operator’s agreement made the demand, source: "operator-agreement" and the
agreement’s reference (a licence’s demand has no source); the
profile and level demanded, the receiver named or its absence, the route
it provides to the licence’s endpoint where it provides one, whether the
scope clears egress (telemetry_egress_cleared, recorded for the reader; it
does not decide the demand), consent (§Reporting consent), met, and the
first reason it is not, the marker included. consent_needed is true where every other
check passed and the demand is unmet for want of consent alone, so agreeing
would admit the source; status, doctor and the console count those
refusals. A demand of another type has no entry there; its refusal is the
record of it. Where more than one telemetry demand was ruled on (several
governing licences, or a licence and the operator’s agreement),
declarations.reporting is the first that is unmet, else the last, and
declarations.reporting_demands holds every one in the order ruled: each
governing licence’s, then the agreement’s. With one demand
reporting_demands is absent.
"reporting": {"source": "operator-agreement", "reference": "agreement-42", "receiver": {"origin": "https://hub.example", "digest": "sha256:<16 hex digits>"}, "route": "hub", "telemetry_egress_cleared": false, "consent": {"state": "agreed", "at": "…", "text_version": "1"}, "met": true}receiver names the receiver by origin and digest, never by its path, query
or credentials, which can hold a key: {"origin": "https://hub.example", "digest": "sha256:<16 hex digits>"}, the digest over the value as
relay.json holds it, so it matches the operator’s own file and tells two
receivers at one origin apart. origin is absent for a value that is not a
URL. Records written before 0.4.10 hold the receiver as a URL string.
route is hub or licence_endpoint (above); it is absent where no
receiver is configured or the receiver is no route, and present on a demand
unmet for another reason, so met alone says whether the demand is met.
content_telemetry_id is the UUID a mediated fetch sent as its
Content-Telemetry-ID request header (Content Telemetry section 7.2), so
an owner that logged the header can match the retrieval event the relay
projects to its own line; the relay carries it on the retrieval event and on
nothing else. It is sent only to a host where a manifest was verified or a
licence was read before the request, so it is absent on the first fetch of
a new host, on a host that has declared nothing, and on every search
result. It is re-attached on a same-domain redirect and left off a
cross-domain one. A mediated fetch the origin answered outside 2xx, or that
no origin answered, is on this record with its http_status or failure
and is never projected as a retrieval.
allowance is the allowance gate’s account of a fetch whose licence, read
before the request, quoted a price for AI input and whose principal declares
an allowance: the consultation, the quoted price reserved before the request
(decision: reserved, proceeded_with_breach or declined), and its
settlement against the receipt. Strict refuses an exhausted or incomparable
allowance before the request; observe and prefer record the breach and go on
to the licence’s terms. No settlement rail that pays a quoted price is built,
so the licence’s payment term refuses the fetch before the request in every
mode, and the reservation is released on that refusal with nothing paid; a
rail that pays reconciles the same reservation against what it charged. Absent where no licence quoted a price or the principal declares
no allowance.
named_by says who named the source of a mediated fetch: user when a
prompt of this session carried the URL the fetch was asked for, agent when
prompts were recorded for the session and none carried it, unknown when the
log holds no prompt_sources record, which is every host without the prompt
hook. It is decided from the recorded hashes (§Prompt sources) against the
URL as asked, with and without a trailing slash. “Provided by the user”
means the user supplied the content bytes; a URL the user pastes is a
reference, and the fetch is acquisition by the system, so named_by is a
fact for the operator’s policy and never a carve-out from a preference.
The operator’s terms reference for the host is recorded separately from
source statements under declarations.terms, with the fields
source policy defines. It is a record of the
reference alone: the source’s statements and the policy mode decide whether
content is admitted, and a reference, a supplier API subscription included,
supplies no reuse permission and no declared licence.
Terms may name the institution identifiers the agreement attributes usage
to (access_context). They require the standard’s session-level
access_context container on any report. The event batch the relay delivers
has no session data, so the relay withholds a crossing whose host falls
under such terms and counts it as withheld for that reason, until
session-document delivery is built. The session’s other crossings report no
use of that host and are delivered as their clearance says. licence.state is declared with the licence URL where RSL terms
were read, and unknown otherwise. Reaching a page establishes no
permission.
Embedded credentials on acquisition describes verification before transformation; the pasted-content reader below is a separate path.
Reporting consent
Section titled “Reporting consent”Owner decision, 27 September 2026 (consent before an obligated crossing).
The operator’s consent to report to sources whose licence demands it, not a
scope’s allow_telemetry_egress, fills the reporting slot of the ruling
above. It is asked once, at install, and recorded in
$COMMONMEASURE_HOME/consent.json:
{"reporting": {"answer": "agreed", "at": "2026-09-29T10:00:00Z", "text_version": "1"}}answer is agreed or withdrawn; text_version is the version of the
consent text commonmeasure consent show prints. The file is written by
commonmeasure consent agree and withdraw and by the installer on the
operator’s answer, and by nothing else: deployment.json and a hub-managed
policy cannot set it, since both refuse members they do not define and
neither names this file. A scope’s clearance is not read as consent, and an
existing home has no consent until the operator gives it.
- With consent agreed, a telemetry demand is met in every scope, including
one that sets
allow_telemetry_egress: false, where the other checks above pass. - Without it (no file, or
withdrawn), a demand is refused in every scope, a cleared one included. The record’srefusalnames the source, that it needs reporting, and the command that agrees: “<host>needs reporting: its licence<url>requires telemetry reporting of each use (profile<profile>), and the operator has not agreed to report to sources that require it; agree withcommonmeasure consent agree.” The refusal the agent reads says the same of “the source”, with the host, the licence and the profile left to the record. - A file that does not read or parse, or records a text version this binary does not know, is not consent: the demand is refused and the reason names the file’s error rather than saying consent was never given.
- Consent counts only from a regular file owned by the user running Common
Measure and writable by neither its group nor others. The file is opened
without following a symbolic link (
O_NOFOLLOW) and its type, owner and mode are read from the open descriptor, so the file checked is the file read. A link, whatever it points to, a file of another user and a group- or world-writable file haveconsent.stateunreadable, and the reason names the fault, for example “consent.jsonis a symbolic link” or “is writable by others (mode 666)”. The writers replace the file atomically at mode 0600 whatever the umask; a link at the name is replaced, and its target is left as it was. Where the platform has no unix owner or modes, only the link and file-type checks apply.
The file is read at each ruling, so a withdrawal refuses the next crossing
of a server already running. Withdrawal recalls nothing: a crossing admitted
under consent records consent {"state": "agreed", "at", "text_version"},
and the relay reports it by that record whatever the file says when it runs
(telemetry projection). consent.state is
agreed, withdrawn, not_given or unreadable. The consent of the
operator home governs every session in it: stdio sessions, the hosted
service’s sessions and the background relay alike. Payment and use-limit
obligations do not take this consent; the decision leaves them open. Tested
in crates/commonmeasure-harness/src/mcp.rs
the_reporting_consent_decides_a_demand_in_every_scope and in
crates/commonmeasure-cli/tests/mediated_e2e.rs
reporting_demand::a_reporting_demand_is_refused_without_consent_and_admitted_in_every_scope_with_it,
the_reporting_ruling_reads_the_consent_at_each_fetch_of_one_session,
what_the_edge_admits_under_consent_is_what_the_relay_delivers,
status_and_doctor_name_the_sources_refused_for_want_of_consent,
a_linked_or_writable_consent_file_admits_nothing_and_the_relay_sends_nothing,
a_demand_under_terms_needing_access_context_is_refused_and_nothing_is_delivered,
a_demand_into_a_session_log_the_relay_cannot_read_is_refused_and_nothing_is_delivered,
a_later_crossing_admitted_or_refused_does_not_strand_an_earlier_reported_one
and
a_demand_after_a_held_crossing_is_admitted_and_delivered_without_it, and on
a hosted session in crates/commonmeasure-cli/tests/hosted_service/reporting.rs
a_hosted_session_keeps_its_earlier_report_when_a_later_crossing_is_held;
the file checks in crates/commonmeasure-harness/src/consent.rs, and the
installer’s mode under umask 000 in crates/commonmeasure-cli/tests/installer.rs
the_installer_writes_trusted_consent_under_umask_000_and_names_what_leaves.
Manifest discovery
Section titled “Manifest discovery”After a mediated fetch succeeds, the server resolves the host’s Content
Telemetry discovery manifest (/.well-known/content-telemetry.json,
standard section 8) and writes a manifest_resolved record; the crossing
carries the record’s seq as manifest_record. Concurrent writers can reuse
sequence numbers. The console displays manifest details only for a unique
preceding reference with the crossing’s host; missing, foreign or ambiguous
references are labelled unavailable or ambiguous
(crates/commonmeasure-harness/src/manifest.rs,
crates/commonmeasure-harness/src/discovery.rs). The manifest identifies an
owner and its telemetry endpoint and carries no demand, so discovery never
delays the crossing’s ruling and never refuses anything. The one exception is
a host that states a Crawl-delay: its manifest is asked before the page, at
the crossing’s first free turn, and the page waits behind it; where the host
is not clear the probe is not sent, the record says cache: "not_asked", and
it is held until the host may next be asked (§Source declarations).
{ "session_id": "…", "host": "news.publisher.example", "timestamp": "…", "cache": "fetched", "fetched_at": "…", "expires_at": "…", "max_age": 3600, "probes": [{"url": "https://news.publisher.example/.well-known/content-telemetry.json", "status": 404}, {"url": "https://publisher.example/.well-known/content-telemetry.json", "status": 200}], "outcome": "verified", "facts": {"schema_version": "1.0", "id": "https://publisher.example/.well-known/content-telemetry.json", "roles": ["content_owner"], "operator": "Publisher Example", "telemetry_endpoint": "https://telemetry.example.com/v1/events", "domains": ["publisher.example", "*.publisher.example"], "key_ids": ["key-1"]}}The consumer rules are the standard’s (section 8.7): a 404 or a network
error leaves the participant unverified and rejects nothing (not_published
and unavailable); invalid JSON, a schema failure, a duplicate key id or a
domains entry that is not the manifest’s own host or a subdomain of it
rejects the manifest with the reason (rejected); any 1.x version is
accepted. When the host answers 404, the manifest at its registrable domain
is asked once, because a manifest there may claim the hosts beneath it
(section 8.6), and probes lists both. The registrable domain is the host’s
public suffix under the Public Suffix List, ICANN and private sections, and
the one label before it; the list is compiled into the binary, so no request
leaves the edge to learn it. It is asked only where it differs from the host,
and nothing else is asked, whatever it answers: a.b.example.com asks
example.com and never b.example.com, www.bbc.co.uk asks bbc.co.uk,
and bbc.co.uk, x.pages.dev and user.github.io ask nothing further, so
the fallback never asks a public suffix such as co.uk or pages.dev. A
suffix host is asked only where the page itself is on it, and then for its
own robots.txt and manifest and nothing further, or where a redirect the
publisher serves leads there, and then under that host’s robots.txt. An
address or a single-label host asks nothing further. A manifest at an intermediate host,
such as b.example.com for a.b.example.com, is not found. The trailing dot
of a host is removed before the lookup and before the cache key.
Each probe is ruled by robots.txt at its own origin before it is sent, in
every policy mode (§Source declarations): the page host’s copy, already read
for the page, and the registrable domain’s, read before its probe. A
redirect from a probe is ruled at the redirect’s origin and takes its
target host’s Crawl-delay turn (§Source declarations); a redirect whose
turn does not fit is declined, outcome is unavailable, and the record
is kept for five minutes. A probe that is
refused is not sent; its entry in probes has no status and names what
refused it in refused_by, and outcome is refused with the reason. Two
refusals land there, told apart by refused_by: "robots.txt", where the
host’s robots.txt disallows the URL or a redirect from it, and "policy",
where this edge sends no request to the URL (the operator’s policy, the
address floor or the hub’s origin). refused leaves the participant
unverified and rejects nothing, as not_published does. A robots.txt
refusal expires when the copy of robots.txt that refused it expires; a
policy refusal is kept for five minutes. cache is
not_asked where no manifest request was sent on the crossing, and
fetched where one was, including one that was answered with a redirect
that was not followed or that the call’s time limit ended after it was
sent. A probe this edge could not sign, or had no time left to send, was
not sent.
{ "session_id": "…", "host": "news.publisher.example", "timestamp": "…", "cache": "fetched", "fetched_at": "…", "expires_at": "…", "probes": [{"url": "https://news.publisher.example/.well-known/content-telemetry.json", "status": 404}, {"url": "https://publisher.example/.well-known/content-telemetry.json", "refused_by": "robots.txt"}], "outcome": "refused", "reason": "https://publisher.example/robots.txt disallows CommonMeasureBot at https://publisher.example/.well-known/content-telemetry.json: the `User-agent: *` group, which addresses every fetcher because no group names CommonMeasureBot; rule `Disallow: /`, a literal path prefix"}A release that does not know the outcome refused cannot read a record
that carries it. The readers of a stored manifest record are the per-host
cache under manifests/, which such a release treats as absent, probing the
host again and overwriting the file; the console, which shows a session
record’s fields as written; and the relay, which projects nothing from any
manifest record. Run one release per $COMMONMEASURE_HOME: a home that a
release writing refused has used is not read by a release that does not
know it.
The outcome is cached per host under $COMMONMEASURE_HOME/manifests/<host>.json
for the response’s max-age, an hour when it names none, a day for a 404 and
five minutes for a failure, written whole and renamed into place; a crossing
under a live cache entry records cache: reused with the same facts. The
manifest’s id must name the host it was fetched from, after redirects. Every probe goes through the same host policy and address
floor as the page, and is ruled by robots.txt at its origin as above. The relay projects nothing from this record
(crates/commonmeasure-relay/tests/relay.rs).
Prompt sources
Section titled “Prompt sources”The prompt-submission hook records, per prompt, the URLs the prompt named and
the statements pasted material carried (crates/commonmeasure-harness/src/prompt.rs):
{ "session_id": "…", "host": "claude-code", "timestamp": "…", "url_hashes": ["sha256:…"], "url_hash_basis": "sha256 over the URL text as written in the prompt, trailing punctuation removed", "statements": [{"source": "pasted-c2pa", "category": "ai-input", "preference": "disallow", "detail": "cawg.training-mining cawg.ai_inference: notAllowed"}], "references": [{"kind": "constraint-info", "href": "https://publisher.example/license.xml"}], "c2pa": {"method": "annex-a8", "validation_state": "valid", "claim_generator": "…", "training_mining": {"cawg.ai_inference": "notAllowed"}}, "embedded": "statements read", "not_scanned": ["images: the prompt hook receives text only, so a manifest embedded in a pasted image is not read"]}No prompt text and no URL text enters the record: a URL is kept as its hash,
so a later mediated crossing can be matched to it. Pasted material is read
for an RSL licence in a <script type="application/rsl+xml">
(pasted-rsl), a <link rel="license" type="application/rsl+xml"> (recorded
as a reference, not fetched), a Content-Usage: line in a pasted HTTP
response (pasted-content-usage), and a C2PA manifest embedded in text under
Annex A.8 or in HTML under A.7, read with the C2PA SDK (pasted-c2pa). From
the manifest the training-and-data-mining entries are taken: allowed is
allow, notAllowed and constrained are disallow, and a constraint_info
URL is recorded as a reference for the mediated path to follow. The SDK’s
validation state is recorded as reported; without a trust list a well-signed
store is valid, not trusted. Plain text with nothing embedded is recorded
as no embedded statement, which is unknown. A pasted image is not read,
because the hook receives text only, and the record says so. The hook makes
no network request.
The nudge issuance
Section titled “The nudge issuance”The SessionStart hook, the plugin’s or the one install claude writes,
emits the standing mediation nudge (plugin/README.md §The standing nudge)
and records that it did:
{ "session_id": "…", "host": "claude-code", "timestamp": "…", "nudge": "mediation-nudge/7", "source": "startup", "basis": "emitted on the SessionStart hook's stdout for the host to add to the session's context; injection is the host's act and is not witnessed"}The record also carries policy_identity, or policy_unavailable where
the hook could not read the policy (§Policy identity).
nudge names the versioned wording in force
(crates/commonmeasure-harness/src/nudge.rs), so a review counting mediated against
observed crossings can split sessions that were asked from sessions that were
not. source is the host’s own account of how the session began (startup,
resume, clear, compact); each of those starts re-delivers the nudge,
because each rebuilds the context the nudge is in. The basis is in the
record because only emission is witnessed: adding hook stdout to the model’s
context is the host’s act. Nothing records whether the agent read or obeyed
it; the session’s own crossing counts measure obedience.
Instance registration
Section titled “Instance registration”A working instance is registered with the hub the edge is enrolled with
under the instance registration contract. The
edge half is built against the hub’s first increment and names the
reporting duties of its second; the client names no entitlement or shared
limit. Integration state: fixture-tested
(crates/commonmeasure-relay/tests/instance_registration.rs, against a test
double of the hub that verifies the signature by the hub’s pinned rule). The
ignored test in crates/commonmeasure-cli/tests/instance_e2e.rs drives the
binary against a running hub, and has been run against the real hub server
and database on loopback. The second test there carries
a reporting duty through relay to the hub’s read of it, against a hub
started with its development onward setting (DEV_ONWARD_LOOPBACK, which a
hub accepts only in development mode on a loopback listener) so that it
delivers to the test’s loopback receiver. That receiver is a test double
which refuses every delivery until the test tells it to accept. The hub
reads the duty outstanding while the instance is active; outstanding
with no acceptance after closure, once the double has refused a delivery;
and delivered, with the receiver’s acceptance, after the double accepts
the hub’s retry. The run establishes this for the edge and the hub on
loopback and for no receiver.
crates/commonmeasure-cli/tests/instance_recovery_e2e.rs is the third such
test, run the same way: an instance’s
process and a relay are each killed with SIGKILL and a later process
recovers delivery (A killed instance, below). Its hub is started with
the same development onward setting and stands behind a pass-through
forwarder in the test, and its onward receiver is the same kind of test
double.
No hosted deployment has been exercised.
The signature. Each lifecycle request is signed with the key minted at
enrolment (crates/commonmeasure-harness/src/identity.rs
sign_registration). The signature covers @method, the absolute
@target-uri, content-digest (SHA-256 of the exact body bytes; of no bytes
for a read) and idempotency-key, with created, expires (300 seconds
later), keyid, alg="ed25519", a fresh nonce and
tag="commonmeasure-registration". The hub’s interoperability vector is
copied to crates/commonmeasure-relay/tests/fixtures/registration-request.json
and reproduced byte for byte: the base, the headers and, Ed25519 being
deterministic, the signature. The two files change together. The target is
the origin the hub returned at enrolment plus the route path, because the hub
rebuilds the target from that origin; the request is sent to the hub URL
connect was given.
The hub refuses a created later than its own clock and allows no grace, so
the edge dates created 30 seconds before its own clock
(REGISTRATION_SKEW_ALLOWANCE_SECS) and expires 300 seconds after that: a
clock up to 30 seconds ahead of the hub’s is accepted, and a signature is
good for 270 seconds from sending. RFC 9421 does not require created to be
the sending time. A clock further ahead, or more than 270 seconds behind, is
answered 401 invalid_registration_signature. That answer and 401 registration_signature_required are the hub turning the request away before
it looks at the key’s standing or the instance; 401 nonce_reused is raised
once the key has verified, when the nonce is consumed. None of the three
rules on the instance, so all three are recorded unavailable, never
refused, and the operation keeps the hub’s server_time as hub_time
beside the edge’s own at.
The commands. commonmeasure instance register sends the typed work
references (the session id as a session reference in the declared host’s
namespace, a job id as a job reference in context-job), the host
declaration, the owner’s delegation and custody acceptance by their hub
identifiers, the requested expiry, an optional parent and predecessor, and
the applied source policy: the envelope retained in
managed/last-known-good.json with the digest managed/state.json records
as applied. renew restates the retained registration with the policy then
applied and the expected revision; close sends the outcome and final
evidence digests; status reads the standing. register --duty <reference>
(repeatable) names a reporting duty in the hub’s form,
owner:<content owner id>; a renewal restates it, and close reports every
duty the registration named as outstanding, because the hub reads a duty
delivered only after the instance has ended and the edge has no later
knowledge to state. The hub binds an idempotency
key to the exact body bytes, so each mutation’s body is kept at
<home>/instances/attempts/<idempotency-key>.json before it is sent. A
command run again with the same --idempotency-key resends those bytes under
a fresh nonce and ignores the arguments of the second run, because a
recomputed expiry would otherwise turn the retry into
409 idempotency_conflict. An edge that is not enrolled,
not managed or has activated no policy revision is refused before any
request, naming what is missing. So is:
- an idempotency key outside the hub’s rule, 1 to 128 of
A-Z a-z 0-9 - _, which the hub would answer401 invalid_registration_signature; - a key whose kept attempt was made for another hub: the kept body names that hub’s delegation and carries its policy envelope, so the error names both hubs and asks for another key;
registerfor a session that already points at an instance whose retained standing isactiveand whose window has not passed, unless--replace-sessionis given. With the flag the new record keeps the earlier instance assession_taken_from; the earlier instance is not ended at the hub and still closes. A retry under the key that registered the instance the session points at is not refused.
What is kept. <home>/instances/<instance>.json, mode 0600 in a 0700
directory (crates/commonmeasure-harness/src/instance.rs Retained): the
hub, the issuer, the instance, the revision, the standing the hub last
answered, the work references and session, valid_from, expires_at,
next_check, online_validation_required, the registration as sent without
the policy envelope, the signed accepted binding exactly as returned, the
closure receipt, each reporting duty as the hub last answered it (duties:
reference, state, event counts and receiver acceptances; the hub’s statement,
following its latest answer: absent until an answer carried a duty, removed
when an answer’s duties names none, and left as it is by an answer with no
duties array), and one entry per operation with its idempotency key, HTTP
status, outcome (accepted, replayed, refused, unavailable,
unreachable, binding_rejected, closure_unacknowledged) and the hub’s
stable reason. The attempt file also holds what the last send of it did, so
a registration that produced no instance still leaves its refusal, and the
instance a registration produced.
The summary printed by instance status, register, renew and close
carries closure: the outcome, time and custody receipt as this edge retained
them, null where it holds none, including a closure the hub did not
acknowledge; status does not fetch it.
<home>/instances/by-session/<session-id> names the instance a session’s
records are attributed to; closing the instance removes it, and disconnect
removes every pointer and keeps the records, because the instances belong to
the key given up and the hub answers 404 for them to a key enrolled later.
After disconnect those sessions are unregistered: nothing is checked and no
member is added.
The mediating server and the CLI both append to one record. Each reads the
record again and writes it while holding an exclusive lock on
<home>/instances/.lock (created readable by the owner only), after the
hub has answered and never across a request, so neither drops the other’s entry; a standing older than the
revision held is ignored. Files are staged under the whole file name plus a
value unique to the write, because session identifiers may contain dots.
An accepted binding is retained only if its signature verifies under the key
the answer names, the signed bytes are the binding shown, the purpose is
instance-registration, the binding names this edge key, the organisation in
the enrolment record and, as issuer, the origin the hub returned at
enrolment, and it agrees with the request it answers: policy.revision and
policy.digest with the envelope sent, policy.applied_digest with the
digest sent, registration.work with the work sent, every duty the request
named present in both registration.duties and the terms.reference of the
binding’s duties (the binding may carry more, from a parent or
predecessor), and expires_at no later than the expiry asked for. The edge pins no registration signer yet, so this
does not establish that the signer is one the operator trusts; the policy
signer pin is not reused for it.
A binding that fails is binding_rejected. The hub has made the revision
whatever the edge thinks of it, so the instance is recorded with the hub’s
instance and revision and the standing binding_rejected, which status
reads and close ends. A rejected registration names no session: nothing is
attributed to it. A rejected renewal keeps the last binding that verified,
takes the hub’s revision so that closure states the right one, and leaves the
session pointed at the record: its crossings are refused
instance_binding_rejected until the operator closes the instance or
registers again. The hub answering active does not clear the standing.
A closure reports as outstanding the duties the registration named. Under the
standing binding_rejected it reports only those the hub’s latest binding
also restates in registration.duties, because the hub holds a closure to the
binding it signed last and refuses a duty that binding lacks
(unknown_duty_reference), which would leave the instance active until
expiry. For a rejected renewal the record keeps that latest binding’s accepted
document, unverified, beside the binding that verified (rejected).
The binding applied. Every record appended to the session’s log while
its pointer exists carries instance (§Where), read from the retained
record at each append, so a renewal changes the revision on the records that
follow it. Before a context_fetch or context_search crosses, and after
the source policy’s host ruling, the MCP server checks the retained binding:
| Condition | Outcome |
|---|---|
the retained standing is not active |
refused, instance_<standing> |
expires_at is not after now |
refused, instance_expired; the hub is not asked |
online_validation_required, or next_check has come: the signed read of the instance answers active |
the work proceeds; the revision, expiry and next check are taken from the answer |
that read answers another standing, or 401 edge_inactive, 401 unknown_edge, any other 401 not named in the next row, 403, 404 or 409 |
refused, with the standing or the hub’s reason |
that read answers 401 invalid_registration_signature, 401 registration_signature_required or 401 nonce_reused: the hub did not accept the request’s signature and ruled on nothing |
unavailable, instance_check_unavailable, with the hub’s reason and both clocks |
| the hub is not reached within five seconds, answers anything else, or the retained record cannot be read | unavailable, instance_check_unavailable or instance_record_unreadable; there is no grace |
The hub’s first increment sets online_validation_required and a
next_check equal to the time of issue, so every crossing is checked. A
stopped crossing appends instance_refused with tool, outcome, reason,
the url for a fetch, and the instance reference, and the tool error says
refused before the crossing or unavailable before the crossing. It is not
a crossing_refused: the source policy did not refuse the source, and the
record adds nothing to the wire’s refused count. The source policy is ruled
on as before, whatever the registration says: a registration admits nothing
the policy refuses, and a stale cached policy stays enforced. Only the
validity window’s end is judged by the edge’s clock; whether the window has
begun is left to the hub’s answer.
A killed instance. The edge keeps no state in the instance’s process that
recovery needs. Each record is synced before its append returns and carries
the instance reference it was written under, the retained binding is a file,
and delivery state is the spool’s (telemetry projection §Delivery state). After the process is
killed, commonmeasure relay from the same home, run by any later process,
projects the session’s records and delivers them to the issuing hub with their
reference. A relay killed after it claimed a batch leaves the claim with no
outcome; the batch goes again at the claim’s persisted deadline with the same
event identifiers and instance members, and the receiver’s deduplication by
event identifier is what keeps a batch it had already accepted from counting
twice.
Nothing closes a killed instance for it. The hub reads it active and its
duties outstanding until an operator runs instance close from the same
home, or its window passes and the hub reads it expired; the hub accepts a
closure while standing is active or expired (closing an expired
instance is not exercised from this edge). Either ends the
instance, and the hub reads a duty
delivered only after that and after the onward receiver’s acceptance. The
edge has no command that finds the instances whose process has gone, and
chooses no closure outcome on the operator’s behalf.
A final record cut short by a kill inside an append is marked by the next
append, or by the relay before it reads for delivery, and the log then reads
(§Reading a log). A line that does not parse and that no gap record marks is
not repaired. The log stays unavailable to a strict reader:
commonmeasure session fails naming the file and the line. A relay run that
reads the log skips that session: nothing of the log is projected, spooled or
sent, the whole records before the damaged one included. The run goes on for every
other session it reads and delivers every due batch in the spool. A batch
spooled earlier from the damaged session is delivered with them in a home
without a directory selection. Where a directory selection applies to the
batch, the recheck before delivery reads the batch’s session log again; a
log that does not read cannot establish consent, so the batch stays queued
and unsent under a hold that consumes no attempt, the session is named with
the skipped ones whether or not the run was scoped to it, and the batches
behind it are delivered. The batch leaves on the first run after the log
reads. The run then fails, naming each skipped session, its file and the
number of its batches left queued, after the report of what it relayed; a
delivery failure in the same run names them as well. The hosted service
journals the report and the skipped sessions at each interval when no
delivery failed; a delivery failure is journalled under “relay did not run”,
with the skipped sessions named after it; the relay did run (EGR-42). The
relay keeps the sessions it last skipped in relay/skipped-sessions.json,
and commonmeasure status,
commonmeasure doctor and the egress block of the console’s /api/status
(skipped_sessions, and in detail) name them, because a run that delivered
everything else leaves no delivery error. A run of the whole home replaces
the list; a run scoped with --session replaces only the entries of the
sessions it opened or skipped. Records already delivered
stay delivered. A session record carries no seal and no chain; those
belong to a published run. What can be checked after exit is that every line
is a whole record, that inspect joins each output to the acquisitions it
cites, and that an output’s bytes hash to its recorded output_hash.
Integration state: fixture-tested by
a_batch_accepted_before_the_relay_was_killed_is_sent_again_with_the_same_ids_and_reference,
a_cut_short_final_record_is_skipped_by_name_and_the_other_session_is_delivered,
a_damaged_log_does_not_hold_back_another_sessions_duty_bearing_batches,
a_batch_whose_log_is_damaged_later_stays_queued_under_a_directory_selection
and a_batch_whose_log_is_damaged_later_is_delivered_without_a_directory_selection
(crates/commonmeasure-relay/tests/relay.rs),
a_damaged_session_log_is_named_after_the_report_of_what_was_relayed
(crates/commonmeasure-cli/tests/relay_e2e.rs),
an_expired_instance_stops_its_session_while_a_stale_policy_still_rules
(crates/commonmeasure-relay/tests/instance_registration.rs) and
a_killed_writers_output_still_joins_its_acquisition_and_a_lost_final_append_is_explicit
(crates/commonmeasure-harness/src/observations.rs), each against a stated
double. The run named above exercised the same properties against a real hub
server and database on loopback. The hub was started with its development
onward setting, behind a pass-through forwarder in the test that withholds
one answer so the relay can be killed at a known point, with a test double as
the onward receiver. In that run the output is a string the test supplies as
host and content_hash is compared with the killed process’s own answer, so
its hash checks show that the record survived the kill and joins after exit;
they are not an independent reading of the page or of an output artefact.
No hosted deployment and no real receiver has been exercised.
A session with no pointer has no registration: nothing is checked, no member is added and no request is made. Hook and import records are stamped like any other record of the session; they are observed after the fact, so the check cannot stop what they describe, and that coverage gap is unchanged.
Policy identity
Section titled “Policy identity”The digest of the effective policy a session resolved
(docs/GLOSSARY.md §Policy identity), recorded so a reader can tie each
governed record to the policy that governed it without the policy document,
and so two edges can be compared for drift by digest alone. It is SHA-256
over the canonical JSON of the pre-image
(docs/contracts/canonical-json.md). The pre-image itself, the versions it
is taken under and the rest of the recipe are
docs/contracts/fleet-status.md §Policy identity; this section says where
the digest appears in a session and what its absence means.
Three record kinds carry it:
crossing_mediatedandcrossing_refused:policy_identityis the digest of the policy the mediating server resolved at start and ruled under. The server resolves once, so every crossing in one server process names one identity, and a policy edited while the server runs reaches the record only when a server next starts.turn_started,turn_completed:policy_identityis the digest of the policy a mediated crossing in the boundary’scwdwould meet, resolved from the same document by the same resolver. A hook enforces nothing, so this is the identity of the policy in force at that boundary and not a claim that anything was enforced by it.nudge_issued:policy_identity, resolved the same way against thecwdthe host reported at session start.
On every record kind the field sits at the top level of the payload, so one rule reads them all.
A hook that cannot read the policy records policy_unavailable with the
loader’s reason in place of policy_identity. It does not record the
identity of the permissive default, because the operator did not write
that policy, and a mediating server refuses to start under the same file.
A record carrying neither field predates the field. commonmeasure session counts those records and says they predate it; no reader treats
their absence as “no policy governed this”, and no older record is
rewritten to carry one.
The digest is drift evidence. An edge computes it about the policy it loaded and reports it about itself, so a matching digest on two edges says they resolved the same effective policy and nothing about whether either edge was tampered with.
Client identity
Section titled “Client identity”host on every record is the word the registration passed to
commonmeasure mcp --host: claude-code, codex, pi, claude-desktop,
cursor, copilot-cli or vscode; the first run’s own session carries
the installer’s word instead (§The installer’s word). The server refuses
any other value at start, but --host defaults to claude-code, so a registration that passes
no --host (Goose’s hand-written extension, for one) is recorded as
claude-code whatever the client is; the client field below is what tells
them apart, and the reporting ruling reads it (§Source declarations).
Observed crossings also carry the hook command’s host words, which include
the browser surfaces chatgpt-web, google-ai-overview and
bing-copilot-search (docs/contracts/host-integration.md §2). The
same word covers several programs: one [mcp_servers] table serves the
Codex CLI, the ChatGPT desktop app and the Codex IDE extension, and Claude
Desktop connects its chat and its local agent mode as two clients. What
tells them apart is the client’s own word for itself, the clientInfo it
sends in the protocol’s initialize request, which the server holds from
the handshake and records once, before the first record a tool call leaves:
{ "session_id": "…", "host": "codex", "timestamp": "…", "client": {"name": "codex-mcp-client", "version": "0.154.0", "title": "Codex"}, "protocol_version": "2025-06-18", "negotiated_protocol_version": "2025-06-18"}protocol_version is the revision the client asked for and
negotiated_protocol_version the one the server answered with, which
decides what the client could send. The server serves 2025-03-26,
2025-06-18 and 2025-11-25; it answers a client that asks for one of those
with it, and any other client with 2025-11-25, the latest, as the
protocol’s version negotiation states
(https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle).
A client that asked for 2024-11-05 is recorded with the two values
different.
A stdio server that is started and never asked for anything leaves a
session file holding its start records only: host_process (§Host
process) on every edge, and edge_identity and policy_sync on an edge
that is enrolled with a hub or runs managed policy. It leaves no client or
credentials record, with or without an operator credentials file, because
credentials_loaded and client_identified both wait for the first record
a tool call leaves and context_status leaves none. A hosted session
on the same edge opens its file at its first request beyond the lifecycle
(§Where), so one asked for nothing leaves none.
The same client object is stamped on every crossing_mediated and
crossing_refused record the server writes after it, so one crossing
answers which program made it without a search back to the session’s start.
title and protocol_version are present only where the client sent
them; negotiated_protocol_version is present on every such record. A
client that sends no
clientInfo leaves no client_identified record and no client field on
its crossings; the absence is the fact, and no default name is put in its
place. Observed and reconstructed crossings never carry the field: no MCP
client made them.
Names recorded from the hosts probed: claude-code with the title
Claude Code (every recorded Claude Code session, Claude Code 2.1.270 to
2.1.278), codex-mcp-client (the Codex
CLI at 0.154.x and the ChatGPT desktop app at 0.153.x, told apart by
version), claude-ai and local-agent-mode-commonmeasure (Claude
Desktop’s two clients), cursor-vscode (Cursor). The relay projects
neither the record nor the field; what a receiver learns about the host is
contextops-host-tool.
The installer’s word
Section titled “The installer’s word”One word is recorded without a registration: commonmeasure-first-run, for
the installer’s own program. commonmeasure first-run, which install.sh --connect runs, makes its one governed fetch in a session of its own,
served in its own process. Every record of that session carries the word as
host, and the session’s client is the same word, the name the first run
gives itself in initialize:
{ "session_id": "first-run-…", "host": "commonmeasure-first-run", "timestamp": "…", "client": {"name": "commonmeasure-first-run", "version": "…"}, "protocol_version": "2025-11-25", "negotiated_protocol_version": "2025-11-25"}There is one such word, and it is distinct from every registered host’s.
--host does not accept it, so no registration can be recorded under it,
and installer traffic is never recorded or delivered as a host’s session.
The session sends no session-end event, so a licence’s reporting demand is
met in it only where a background relay or a hosted service holds the home
(§Source declarations). The relay projects the word as it projects any host
word, in contextops-host-tool (telemetry
projection).
Credential provenance
Section titled “Credential provenance”The MCP server loads $COMMONMEASURE_HOME/credentials.env at start
(crates/commonmeasure-supply/src/credentials.rs) and, when a file was actually
there, records what it contributed before the first record a tool call
leaves:
{ "session_id": "…", "host": "claude-code", "timestamp": "…", "credentials": { "path": "/home/op/.commonmeasure/credentials.env", "present": true, "sha256": "sha256:…", "applied": ["EXA_API_KEY"], "shadowed": ["TAVILY_API_KEY"] }}applied names the variables the file supplied; shadowed names the ones
the launching environment already held, because the environment always wins.
The record carries only the file’s path, digest and variable names; a
credential value never enters any record. A session whose log holds
crossings but no credentials_loaded event ran on the launching environment
alone: the absence of the event also answers the provenance question, and a
session with no tool call or host observation does not open a log for this. Because the
inference rests on the record always being written, a server that cannot
append it fails that tool call before fetching anything, naming the log,
and tries again at the next call.
A hosted service configured for supplier credential custody
(docs/contracts/supplier-credentials.md §Edge side) also holds credentials
its hub released. Where it holds any, the record is written whether or not a
file was present, and gains a hub member naming each released credential in
use:
"credentials": { "path": "/home/op/.commonmeasure/credentials.env", "present": false, "hub": [ {"connection_id": "…", "provider": "exa", "variable": "EXA_API_KEY", "fetched_at": "2026-09-19T10:00:00.000Z"} ], "hub_shadowed": [ {"connection_id": "…", "provider": "tavily", "variable": "TAVILY_API_KEY"} ]}fetched_at is when the fetch that last served the credential started, so
a credential kept through an unreachable hub shows the age of its release. A
released credential whose variable the environment or the file already holds
is not used; it is listed under hub_shadowed with its connection id.
shadowed stays what the file section above defines, a list of variable
names, and is present only where a file was.
A released set is used for at most three fetch intervals after the last
fetch the hub answered with a release (900 s at the default interval). The
age is checked where a value is read, so an unreachable hub, a fetch that
cannot be tried and a fetch that has stopped running all end alike. Past
that age hub is empty, nothing is listed under hub_shadowed, and each
credential is listed under hub_expired with the bound it passed:
"hub": [],"hub_expired": [ {"connection_id": "…", "provider": "exa", "variable": "EXA_API_KEY", "fetched_at": "2026-09-19T10:00:00.000Z", "max_age_seconds": 900}]A search for that provider is then refused as any unconfigured provider is, by its variable’s name.
The released set can change while a session runs. Before a crossing made
under another set of connections in use, shadowed or expired than the last
record named, the record is written again, so a session may hold more than
one credentials_loaded and the latest before a search names what that
search could use. No record, context_status answer or error carries a
released value. Fixture-tested; no hub has served a release to this code.
Turn boundaries
Section titled “Turn boundaries”The hooks at prompt submission and at the end of a turn each append a boundary:
{ "session_id": "…", "host": "claude-code", "timestamp": "…", "turn_id": "<the host's prompt or turn identifier>", "privacy_level": "minimal", "privacy_basis": "the boundary records the host's turn identifier, the working directory and the transcript path; it holds no query text, response text, intent or summary, so minimal is the only level it can declare", "policy_identity": "sha256:…", "detail": {"cwd": "/home/operator/code/project", "transcript": "…"}}The record also carries policy_identity, or policy_unavailable where
the hook could not read the policy (§Policy identity).
On Claude Code, detail of a turn_completed also carries
answer_unavailable where the hook read nothing from the transcript
because it could not establish that the transcript held the turn’s final
answer: the reason, such as “the transcript did not hold the turn’s final
answer within 2000 ms”. That boundary then has no context_snapshot and
submitted no context entry or output (host integration §Context entry and citation on Claude Code).
turn_id is the host’s identifier, absent when the host sends none and
never invented. An observed crossing in the same turn carries the same
value, which is what makes a crossing without its turn’s question useful: a
reader groups the crossings under the boundary, sees when the turn began
and ended, which directory it ran in and which host transcript holds the
question, and reads the question there under the host’s own access rules if
authorised. The question itself never enters this log, and no record here
would be more useful for carrying it: the operator’s questions (which
sources, at what cost, under which policy, for which principal) are answered
by the crossings, and the compliance reader who needs the prompt has the
transcript path.
privacy_level is declared in the Content Telemetry vocabulary
(schema/telemetry-session.v1.json PrivacyLevel: full, summary,
intent, minimal) and is always minimal, because that is the only level
whose claim the record can honour: the higher levels carry the query text, a
summary, or a classified intent, and this record carries none of them. It is
a constant of the edge rather than a policy field, since a declared level the
record could not honour would be a false claim; a projection that carries a
turn event copies the level and may only lower what it carries. The basis is
on the record so a reader needs no other authority.
Host process
Section titled “Host process”On the hosts that pass no session identifier to the MCP server (Claude Code
and Codex among them), a hook and the server write to two logs: the hook
takes the host’s identifier and the server mints local-<milliseconds>-<process id>
(§Where). The two were started by one host process, and that process is the
join. At session start the hook records host_process; at server start the
MCP server records the same. Each walks up from its own parent to the first
ancestor that is not a shell or a launcher wrapper (sh, bash, zsh,
dash, fish, ksh, commonmeasure-launch, the disclaimer helper
Claude Desktop wraps a server in; crates/commonmeasure-harness/src/host_process.rs
WRAPPERS) and records that process:
{ "session_id": "…", "host": "claude-code", "timestamp": "…", "path": "hook", "writer_pid": 99796, "host_process": {"pid": 94522, "started_at": "2026-09-17T22:55:48Z", "command": "claude"}, "basis": "the first ancestor of the writing process that is not a shell or launcher wrapper, read from the operating system's process table when the record was written"}path is hook or mcp. writer_pid is the process that wrote the
record. started_at is the ancestor’s start time as the operating system
reports it, to the second; a pid alone is not an identity, because the
system reuses numbers, and the pair is. command is the ancestor’s
executable name only, never its arguments, which can carry a prompt. Where
the process table cannot be read, or the walk reaches the root of the tree
without leaving shells, host_process is null and unavailable names
why; nothing is guessed (docs/FAIL-POLICY.md §7).
basis is on the record either way. The table is read on macOS and Linux;
on any other platform the record is written with unavailable naming the
platform.
The hook writes the record after the other session-start records and, like
every hook record, best-effort. The stdio MCP server writes it when it
starts, beside policy_sync and edge_identity and also on an edge that
has neither, and a failed append is a failed start. A server that is
started and asked for nothing therefore leaves a log holding its start
records only, which is what the fourth state below reads. A hosted session
writes no host_process: its client runs in a vendor’s cloud, and the
service’s own parent is a supervisor and not a host.
Two logs on one edge whose host_process records agree on pid and
started_at were written under one host process, and a reader treats them
as one host session: the hook log holds the turns, prompt sources,
snapshots and observed crossings; the MCP log holds the mediated and
refused crossings and the client identity. A log without the record joins
nothing; a reader shows it on its own and says the join is unavailable,
never groups it by working directory or timing. The join says nothing about
which turn a mediated crossing belongs to; that remains timestamp order
between the boundaries. On a host that runs several agents in one process
(Codex’s app server starts one MCP server per thread; Claude Code’s
subagents share the parent’s process) the join names the host process, not
the thread or the subagent, and a reader must not present it as one. The same
holds over time: a host that begins a new session identifier inside one
process (Claude Code after /clear or a resume) leaves several hook logs
naming one process, and all of them join with that process’s MCP log.
Instance identity is the instance registration contract’s,
which never reuses a process id; host_process is local liveness evidence
and nothing more.
commonmeasure session, given either log’s identifier, reads the joined
logs, so the witnessed crossings it compares against a context snapshot
(§Context snapshots) include the mediated crossings the other log holds.
It orders the records of the two logs by their timestamps, keeps each
log’s own order where timestamps are equal, and prints each log it joined
and the process it joined on, or that it joined nothing and why.
The liveness probe is crates/commonmeasure-harness/src/host_process.rs
is_present: whether a process with this pid and this started_at is in
the table now, or no answer where the platform’s table is not read.
A host process present in the process table is running. Present, with a
turn boundary or a crossing recorded, is seen working. Absent from the
table with no session_ended is gone without a session end, reported
as that and never as completed. A log whose only records are the ones
written at start (policy_sync, edge_identity, host_process,
nudge_issued) is configured, never seen working.
A reader joins on a log’s first host_process record. A session the host
resumes under a new process writes a second record with the new pair; it is
recorded, and a reader that joins on the first alone says so. Reading the
later records is not yet implemented.
The end of a session is recorded where the host reports it:
{"session_id": "…", "host": "claude-code", "timestamp": "…", "reason": "clear"}reason is the host’s word, recorded as given (Claude Code’s SessionEnd
supplies one such as clear, logout, prompt_input_exit or other) and
absent when the host sends none. A crash sends no hook, so the absence of
session_ended is not evidence that a session continues; the process table
is.
Content Telemetry projection
Section titled “Content Telemetry projection”What the relay projects from this record, under which clearances, and how it delivers and retries, is the telemetry projection contract. The record itself never leaves the machine.
Context snapshots
Section titled “Context snapshots”The host’s context report is an observation with a named basis, not ground
truth inferred from transcript size. At each Stop boundary on Claude Code,
the hook reads the host transcript’s most recent model call (the provider’s
own usage counters, which the host records beside each call) and appends:
{ "session_id": "…", "host": "claude-code", "transcript": "…", "basis": "host transcript usage counters: API-reported token usage of the session's most recent model call, read at the Stop boundary", "model": "<the model the host reported>", "observed_at": "…", "context_tokens": 12000, "input_tokens": 20, "cache_read_input_tokens": 11800, "cache_creation_input_tokens": 180, "output_tokens": 40, "inventory": { "basis": "host transcript records up to the same boundary: each attachment record the host injected into context, measured as the character count of the host's serialised record; each message text block, tool input and tool result, measured as the character count of its text; both divided by four; an estimate, never a tokeniser's count, cumulative since the last compaction", "token_basis": "characters/4", "categories": { "acquired_content": {"records": 13, "estimated_chars": 22884, "estimated_tokens": 5721}, "agents": {"records": 1, "estimated_chars": 2780, "estimated_tokens": 695}, "conversation": {"records": 171, "estimated_chars": 353636, "estimated_tokens": 88409}, "instructions": {"records": 170, "estimated_chars": 58104, "estimated_tokens": 14526}, "local_files": {"records": 2, "estimated_chars": 832, "estimated_tokens": 208}, "other_host_records": {"records": 0, "estimated_chars": 0, "estimated_tokens": 0}, "skills": {"records": 1, "estimated_chars": 8712, "estimated_tokens": 2178}, "system_prompt": {"records": 1, "estimated_chars": 12860, "estimated_tokens": 3215}, "tool_definitions_loaded": {"records": 2, "estimated_chars": 9704, "estimated_tokens": 2426}, "tool_listing": {"records": 1, "estimated_chars": 5028, "estimated_tokens": 1257} }, "available_on_demand": {"tools": ["WebFetch", "WebSearch", "…"], "skills": ["…"], "agents": ["Explore", "…"]}, "definitions_loaded": ["WebFetch", "WebSearch"], "invoked": {"Bash": 54, "WebFetch": 10, "WebSearch": 5, "…": 0}, "compactions": 0 }, "unavailable": ["context_limit", "free_capacity", "resident_tool_definitions (the built-in tool schemas travel in the request, not the transcript)", "memory (auto-memory is part of the system prompt and not separable from it)", "cache state per category"]}context_tokens is arithmetic over reported counters (the uncached tokens
plus both cache legs), not an estimate; it is what the provider reported
reading for that call. The basis is in the record, so the number cannot be
read as a tokeniser’s measurement of the window. What this basis cannot see
is listed in unavailable and not estimated
(docs/FAIL-POLICY.md §7): the host exposes no context limit and no free
capacity at hook boundaries, the resident tool schemas travel in the request
rather than the transcript, and auto-memory is part of the system prompt.
Only counters, the model name, a timestamp, record kinds, tool and skill
names and text lengths are read from the transcript; no conversation text is
copied. A transcript
that cannot be read, or a host without this transcript shape, produces no
snapshot, not a snapshot of zeros.
The inventory is a second observation on a second basis, kept apart from
the counters. The host writes one attachment record into its transcript
for each piece of scaffolding it injects into the window, and the reader
files each record, and each message block and tool result, under a named
category: system_prompt (the host’s own prompt snapshot, where the host
version writes one; otherwise listed unavailable), instructions
(instruction files, environment, model and session notices, reminders),
skills (the skill listing), agents (the subagent listing),
tool_listing (the names of the tools loadable on demand),
tool_definitions_loaded (definitions that entered context on demand),
local_files (files and directories the host read into context),
other_host_records (attachment kinds this reader does not know, counted
under their own name rather than filed as instructions), conversation
(prompts, answers, commands the person queued, tool inputs and the results
of tools that acquire nothing) and acquired_content (the results of the
host’s fetch and search tools and of MCP tools, which is the text the
crossings hash). An attachment’s footprint is the character count of the
host’s serialised record, divided by four; a message block’s, tool input’s
or tool result’s is the character count of its text, divided by four. Both
are explicit estimates with the basis on the record, never a tokeniser’s
count, and never added to the API-reported counters. Thinking blocks are
not counted, because the host does not report whether it sends earlier
turns’ thinking again.
The three states of a capability are recorded apart, because a discoverable
tool is not one whose definition entered context or whose capability was
invoked: available_on_demand holds the names the host listed (tools,
skills, subagent types), definitions_loaded the tools whose definition the
host recorded entering context on demand, and invoked every call the
transcript records, by tool name. A name in the first list and neither of the
others was offered and never used.
A compaction rebuilds the window, so every footprint and listing starts
again at one and compactions counts how many times that happened; the
host writes a boundary line and then the summary message, and the two are
one compaction. Invocation counts survive, because a call that happened
still happened.
commonmeasure session prints the last boundary’s inventory and, between
each pair of consecutive snapshots, the API-reported change in
context_tokens beside the estimated growth of host scaffolding (the seven
categories that are not conversation or acquired content), of conversation
and of acquired content, the witnessed crossings recorded between the two
boundaries, and the remainder the estimates do not account for. The
remainder is printed rather than absorbed into a category: the estimates
are on their own basis and do not explain the provider’s count. A boundary
with no counters, or whose inventory carries different categories from its
neighbour’s, is printed as not comparable; nothing is compared against a
zero that was never measured.
commonmeasure session also prints the snapshots beside the session’s witnessed
acquisition (the sum of estimated_tokens over observed and mediated
crossings, on its own characters/4 basis) as a lower bound on acquired
input; it does not claim that sum explains the host’s totals, which include
everything the host assembled.
The host writes the turn’s final answer to its transcript after the Stop
hook fires, so the hook waits, for up to 2 seconds, until the transcript
holds that answer before it reads (§Turn boundaries). A boundary that
waited in vain records no snapshot and names the gap. One lag is
inherent to the basis. The host does not announce every boundary: /clear starts a new conversation and
session, so the old log ends; its final model call may never be observed,
because no later Stop occurs in that session. Compaction keeps the session
and shrinks the context in place, so it appears as an otherwise-unexplained
drop between consecutive snapshots and not as its own record. The snapshots
are boundary observations; this basis cannot claim what happened between two
of them.
Policy synchronisation
Section titled “Policy synchronisation”On a managed edge (docs/contracts/policy-envelope.md
§Deployment mode) the policy is refreshed from the hub at session start,
and the session log records what the refresh did:
{ "session_id": "…", "host": "claude-code", "timestamp": "…", "trigger": "session_start", "policy_url": "https://hub.example/api/v1/policy/desired", "outcome": "already_applied", "revision": 2, "digest": "sha256:…", "reason": null, "applied": {"revision": 2, "digest": "sha256:…", "expires_at": "2026-09-21T01:40:58Z"}, "stale_since": null}trigger is session_start when the session-start hook ran it and
server_start when the MCP server did, which it does for any session whose
log carries no refresh when it starts, whatever the host; a session is
refreshed once. The refresh precedes every other record of the session, so
the nudge_issued record and every crossing name the policy the refresh
left in force. The
outcomes are the ones the envelope contract enumerates, superseded
included, plus unavailable when the deployment or state file did not load
and nothing was asked of the hub. applied is the revision in force after the refresh, whatever the
outcome, and stale_since is its envelope’s expiry once that has passed:
the policy stays in force and the record says since when it has been stale
(docs/contracts/policy-envelope.md §Cadence and
staleness). A local edge writes no such record, because it makes no
management request. The refresh the relay runs before delivery writes to
the managed state file, not to any session.
policy_url is the URL as the deployment file holds it, and null on an
unavailable record. reason is null on a success and otherwise a
sentence for the operator, the same one the managed state file records,
except on unavailable: that reason reaches the session log alone, and the
state file keeps the previous synchronisation. A
reason names the policy URL by its origin alone (scheme, host and port),
because a hand-written policy_url can carry credentials: a name
resolution that did not finish within the budget is name resolution for https://hub.example did not complete within 3 s: timed out, and a refused
policy_url is policy_url at http://hub.example must be an https URL, or http to a loopback origin, after the deployment file’s path. A
policy_url that does not parse has no origin and is refused with the URL
parser’s reason, which quotes no part of it: policy_url is not a URL: invalid port number. Up to and including 0.4.1 the timeout and the refusal
quoted the URL whole, and the console withholds a reason that can hold one
(docs/CONSOLE.md §Record).
What is deliberately absent
Section titled “What is deliberately absent”- Prompts, responses and conversation text. Records carry identifiers and hashes. The transcript path is noted on a turn boundary; its contents are not copied.
- Local and private addresses from observed capture: every private
address as source policy §Recording defines
it, among them loopback, private networks, shared address space,
.local,.internalandfile://. Passive capture sees everything and never asks, so it holds a floor the operator cannot lower except by named prefix, and never by omission:record_internal_prefixesinpolicy.jsonlists internal prefixes ("https://rag.example.internal/","file:///corp/kb/") whose crossings are recorded with the same grades, hashes and token estimates as public traffic. Each prefix must end with/, because the slash is the consent boundary: a prefix without it would also match hosts and paths that start with the same characters, so the loader refuses it. It refuses a prefix with a query or a fragment too. A URL is under a prefix when it starts with the prefix with both in the form path rules are matched in (§Source declarations), so the readings that section gives a page apply, any one of them under the prefix is enough, and the prefix is read as written:https://rag.example.internal/%6Eotes/a,https://rag.example.internal//notes/aandhttps://rag.example.internal/notes%2Faare underhttps://rag.example.internal/notes/. The operator owns that corpus, and a named prefix is written consent. Anything the list does not match stays out (naming one corpus is not consent for localhost as a whole), and an absent or empty list means the floor holds everywhere. The named prefixes appear incontext_status, so the console can say what is being recorded and on whose authority. The mediated tools honour the same prefixes, andallow_private_hostsis the broader grant, because there the agent named one URL deliberately. Reconstructed import keeps the unconditional floor: a prefix names consent from the moment it is written, and it does not retroactively cover transcripts nothing was watching at the time. Internal crossings are operator-record only; any egress projection must treat internal URLs as private by default. The classification is stamped on the record at capture ("internal": true) and egress honours the stamp, so a prefix later edited or removed cannot make a crossing recorded as internal leave the machine. The content view (/api/content) serves an internal-stamped crossing with an explicit"internal": truemarker, because it is part of the record; its context footprint is listed in the internal-use view (/api/internal) alone. - This plugin’s own MCP tools (
mcp__commonmeasure__*) from observed capture. The server that carried them records them, and observing them again would log each crossing twice and extract every URL from the page body that was returned. - Engagement labels, governing or reported. The log records the cwd, a
fact, and each identity is resolved from it by the reader that needs it:
the relay from
policy.json, the sink fromattribution.json. A label written into evidence would have to be rewritten when a rule changes, and evidence is never rewritten. Thecwdis a path on the operator’s own machine and stays in the private record: the Content Telemetry projection selects fields by allowlist and does not carry it. - Any evaluation. No evaluator runs against a session. Batch-run evaluation
is
docs/contracts/run-output.md§Plans.
Reading it
Section titled “Reading it”commonmeasure session # the most recent sessioncommonmeasure session <id> # a named onecommonmeasure session <id> --jsoncommonmeasure session <id> --since 2026-10-09T10:00:00Z --jsoncommonmeasure serve # the session view of the console, on loopbacksession reads the log as a forecast relay run does (§Reading a log) and
writes nothing. Its reporting line says whether the session’s witnessed
crossings are cleared to leave under the policy on disk now: none witnessed,
all cleared, some, or none (withheld). For the crossings not cleared it names,
per directory they ran in, the rule that keeps them here, in the words and
cause values of telemetry projection §Withheld sessions,
and it counts cleared crossings that operator terms hold for
access_context. It names each torn line, marked or not. A log that does not
read fails the command, naming the log and the line, with the words “the relay
skips it”. The standing is taken from this log alone, before any joined log is
added, because the relay delivers each log on its own.
--since <RFC 3339 time> keeps only the records written at or after that
time, in this log and every log joined to it; the counts, the sources and
the report then describe that window (one turn, for the Claude Code mod’s
sources line). A record whose time does not read is kept rather than
dropped. The reporting standing still covers the whole log.
--json prints session, path, joined (the other logs read with it),
since (or null), records (the joined logs’ included), summary,
sources, tears (each path, line, bytes, marked) and reporting:
standing (no_witnessed_crossings, cleared, partly_cleared,
withheld, or unknown with unavailable where the policy does not load),
policy, witnessed, cleared, held_for_access_context and withheld,
the directories as the relay records them. summary is the report’s counts
from the same summariser: observed, mediated, refused, reconstructed,
grounded_witnessed, grounded_reconstructed, named_not_read,
delivered_files and host_observed. sources names each crossing record
in the vocabulary of the provenance line
(host integration §1): grade,
url, host (cut and quoted where the crossing was delivered or the agent’s
own tool made it, withheld for a refused or failed one), terms, ruling
(delivered, refused, failed, or not ruled for an observed or
reconstructed crossing), supplier, cost, receipt, grounded (null
while the context entry waits on the host) and hash; a record that does
not read as a crossing is {grade, unreadable: true}. Beside them, cost totals
the carried crossings (none for none, unknown when any one is unknown,
else the quoted prices summed per currency, or free) and receipts_owed
counts the crossings whose receipt is owed. A log that does not read prints
session, path and unreadable and exits non-zero.
Conformance
Section titled “Conformance”crates/commonmeasure-cli/tests/embedded_credentials.rs provides fixture-tested
acceptance through the real MCP binary and HTTP transport over loopback:
signed text and HTML verify before removal, tampering remains invalid, gzip
retains the coded hash, refused content retains verification, and malformed,
unsupported and externally linked credentials never acquire trust. The
fixtures use ephemeral signing identities; they establish no publisher trust.
crates/commonmeasure-cli/tests/hook_e2e.rs drives the real binary with the payload
shapes a host sends. crates/commonmeasure-cli/tests/mediated_e2e.rs drives the MCP
server over stdio against a real loopback origin. Between them they assert the
hash covers the ingested text, search results do not ground, subagent identity
survives, private addresses stay out, own tools are not double-recorded, the
client’s initialize name and version are recorded once before the first
crossing and stamped on each mediated crossing and refusal, a client that
sends none leaves neither, each requested protocol revision is answered as
version negotiation states, an initialised server that is asked for nothing
records its host process and nothing else with or without a credentials
file, the server’s session
identifier comes from --session, then AGENT_SESSION_ID, then itself, an
identifier that is not a plain name records nothing from any reader, a
page served under gzip carries retrieved_hash over the coded bytes, a
refusal reaches the agent and the log, no payload makes a hook fail, a turn
boundary declares minimal and carries the host’s turn identifier without
the prompt, a Stop boundary records the inventory with the three capability
states apart and the session report attributes the change between two
boundaries, and the standing nudge is delivered and its issuance recorded at
session start, with delivery surviving an unreadable payload and an
unwritable home. crates/commonmeasure-cli/tests/recorded_sessions.rs reads
recorded host sessions, which this repository does not carry
(CONTRIBUTING.md), and pins the report over them, and the snapshot reader is tested over that Claude Code session’s
transcript with its text replaced by same-length filler
(crates/commonmeasure-harness/tests/recorded/claude-code-transcript.jsonl).
Hosted scope
Section titled “Hosted scope”hosted-service.json may declare session_directory, an absolute directory
that exists (host integration §1, the hosted path).
Every session of the service is then resolved as a local session working in
that directory: its policy scope is the scope that directory selects, its
context_status names the directory as cwd, and each crossing records it
as cwd, so the relay resolves the crossing’s clearance by it as it does a
local session’s. At each session’s open, after the managed policy refresh,
the directory must be selected by a scope of the policy in force or by an
enrolled directory (directory enrolment); where
neither selects it the session is not opened, and the answer names the
directory, because its sessions would otherwise run under the top-level
policy the operator declared them out of.
The service writes one hosted_scope record when it opens a session’s file,
before any crossing. Its payload carries session_id, host, timestamp,
basis: "service_configuration" and the canonical directory. It attests
the operator’s declaration only, and no remote client supplies it. The
declaration grants nothing by itself: what the scope clears, the directory’s
enrolment and approvals, a scope’s allow_telemetry_egress: false and the
reporting consent (§Reporting consent) decide what leaves, as they do for a
local session in the directory. The session’s principal is the bearer’s
(host integration §1, the bearer token), and its
binding and the declared directory’s scope apply together as they do for a
local principal (source policy §Scopes and principals). This record
and its path never enter Content Telemetry. Tested in
crates/commonmeasure-cli/tests/hosted_service/reporting.rs
a_hosted_session_under_a_declared_scope_is_governed_by_it_and_its_report_is_delivered,
a_hosted_service_without_a_declared_scope_runs_its_sessions_under_the_top_level_policy,
a_declared_scope_nothing_selects_opens_no_session and
a_declared_scope_and_a_principal_binding_resolve_as_the_contract_says.
Local comparison records
Section titled “Local comparison records”Console retrieval comparisons use contextops-comparison/1 in
<home>/comparisons/compare-<uuid>.ndjson. They use the same durable NDJSON
writer and mediated crossing/processor shapes as sessions, but the relay scans
only sessions/: comparison queries and results have no implicit Hub egress.
comparison_started binds query bytes and SHA-256, ordered selected providers,
requested/effective limit (five), Edge home/directory, effective canonical
policy and identity, and process principal/authentication basis.
comparison_provider_started precedes each supplier attempt;
comparison_provider_finished retains its terminal outcome. A successful
receipt records received/admitted/refused counts, recordable result metadata
and refusal reasons, charge, adapter request latency, total elapsed time,
endpoint, HTTP status where present, adapter version and response hash.
Snippet text and raw response bodies are not retained in this summary.
Unknown charges and missing request latency remain null/unknown. The existing
private-address recording floor also applies to result metadata.
comparison_finished is written only after every selected provider has a
recorded outcome. Completion means the attempt is accounted for; individual
providers can be refused or unavailable. Without this event the reader shows
an incomplete comparison. A started request without a recorded receipt may
have incurred supplier charges. Evidence failure stops later providers and
returns an unavailable result, never an unqualified success. These local
records are neither sealed batch runs nor a Hub summary upload format.
The local JSON download contains private evidence. The separate comparison export projects permitted measurements into an offline report bundle, with explicit query inclusion and no supplier rerun.
Local credential changes
Section titled “Local credential changes”The console’s Sources page writes provider keys to
<home>/credentials.env (docs/CONSOLE.md §Sources). Each change it makes
is appended to <home>/credentials-changes.ndjson with the same durable
NDJSON writer as sessions, under the event credentials_changed. The relay
scans only sessions/, so these records have no Hub egress.
The payload names what changed and never a value: path (the file),
action (set or removed), provider, variable, sha256 (the file’s
digest after the write), previous_sha256 (before it, null where no file
existed) and written_by (console). The digest is computed over the file
bytes as credentials_loaded computes it, so a session whose
credentials_loaded.sha256 equals a change’s sha256 loaded the file as
that change left it. A setting that repeats the stored value is still a
write and still recorded. Where the log cannot be read or appended to,
including a log path that is not a regular file, the file has already
changed; the Sources notice says the change was not recorded and names
the log, and the lock is released.
Provider policy ruling
Section titled “Provider policy ruling”provider_policy_ruled records a provider or principal policy breach before
MCP search dispatch. It names the session, host, tool, provider, outcome
(refused or allowed_with_breach) and reason, without the query or a source
URL. A strict refusal sends no request. Observe/prefer breaches are also
returned by the tool and carried on delivered source records.