Skip to content
Edge documentation

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:

  1. --session, which the host’s registration passes;
  2. AGENT_SESSION_ID in the server’s environment, which Goose sets for every server it starts, so its records share one log per Goose session;
  3. 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).

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_gap record whose payload.torn_line_bytes equals 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_gap record that marks it before its own record (reason: "write_failed", from the file’s last modification, which was the torn write, to the repair, and torn_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 and commonmeasure session read 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.
  • 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 (torn lines, and tears in --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.

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.

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

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.redirects is non-empty), it is the instant of the request to redirects[0].requested_url, not to url, 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_refused refused 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). failure does not imply requested_at: a crossing that stopped before the request also carries failure and 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 text content_hash covers.
  • hash: SHA-256 over the UTF-8 bytes of the part, in the sha256: form. Where the part is the whole text it equals content_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_type is application/pdf for 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, and refusal names it. Where that type held bytes that are not UTF-8, content_type_rendered is true and content_type is 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_type is absent on a body delivered as text.
  • content_hash is SHA-256 over exactly the bytes handed over, after any gzip coding is removed; retrieved_hash is 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.via is local_file (saved under the session’s directory, its path in the result) or embedded_resource (a hosted edge’s resource block). bytes is the size handed over. statement is always delivered as a file, not read by the edge; the PII detector and the injection screen did not run over it.
  • There is no delivered, no estimated_tokens and no token_basis: the edge counted nothing, so the crossing is one of the console’s crossings_without_estimate. grounded is false. The record claims nothing about what the model read, and the relay projects a retrieval, carrying data.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_invoked record for a PDF the declarations admit, with decision abstain (refuse under strict, which refuses the crossing), no findings and a capability_unavailable gap. The unknown verdict is a breach (docs/FAIL-POLICY.md §6): a delivered file’s breach names both screens after any other breach, so a delivered file never carries breach: null. delivered_file is what marks the crossing as a file.
  • A refused file (under strict, or an offset or max_chars on a PDF, or a file that could not be saved) never carries the screens’ sentences: its crossing_refused has the reason in refusal, and breach only where a host, allowance or declaration breach applies, as for a refused text page.
  • requested_at, declarations, identity and allowance are recorded as for any fetch, and robots.txt, a licence or a Content-Usage statement 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_from names the file.

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.

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.

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

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.

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.

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.

Terminal window
commonmeasure import --dry-run # what would be read, per host
commonmeasure import --since 2026-06-01

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

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. Beside http_status, the host answered and this edge could not record the answer in its back-off store, or could not date the host’s next Crawl-delay turn from it, so the answer was not used; failure says 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 rel mentions license and is not UTF-8, or it names a licence and its type is not UTF-8, and the other parameter does not rule out an RSL licence;
  • its rel* does not decode and the member mentions license, or it names a licence and its type* does not decode, and the member’s plain rel or type in its place does not name an RSL licence (where it does, that licence is read);
  • its rel and rel*, or its type and type*, disagree about whether it names an RSL licence;
  • a parameter name is not a token, and the member mentions license and lacks a rel or a type, which that name may have been;
  • it cannot be parsed and mentions license anywhere;
  • 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/1 is /news/1;
  • every other percent-encoding kept, with upper-case hex, so %2f is %2F; an encoded * or $ is never a wildcard or an anchor;
  • a literal * or $ in the URL encoded as %2A or %24, the spelling in which a rule names one (RFC 9309 §2.2.3), so /file-*.html is 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/b is /b, the path the edge requests) and the fragment dropped. A dot segment written in a robots.txt pattern 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: %2F or %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, and reading.access_rule names that rule. An Allow reached by one reading does not lift a Disallow reached by another, so /a//b is refused under Disallow: /a/ and Allow: /a/b, and //news/1 under Disallow: / and Allow: /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/1 falls to a /news/ entry only when no entry covers //news/1 as requested, and /x//..%2Fnews/1 falls 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/x and /premium%2Fx are governed by /, not by a narrower /premium/ entry. Under a permissive /news/free/ entry, /news/free/..%2Fpaid/x is 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-server timeout in 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 record connect writes) and the receiver has the origin of the hub it is enrolled with, the same origin test connect applies 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. hub names 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.lock now (delivery::service_state, the probe doctor and status use); 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, doctor and status say so, and the session is treated as having no automatic delivery. The interval relay skips its run while relay/manual is 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 holds relay-loop.lock in 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” in doctor and status. It skips its runs while relay/manual is 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 serve runs no interval relay and takes no lock, so its sessions are not automatic.
  • --host claude-code with the client claude-code: automatic. Claude Code is the one host whose hook table registers a session-end event (CLAUDE_HOOKS in crates/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_HOSTS in crates/commonmeasure-harness/src/delivery.rs is held to those tables by a test.
  • Every other --host word: 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 but claude-code: unmet, and the reason names the client. --host defaults to claude-code, so the host word alone does not identify Claude Code: Goose has no install and 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 no clientInfo trusts 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.

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’s refusal names 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 with commonmeasure 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 have consent.state unreadable, and the reason names the fault, for example “consent.json is 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.

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

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

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 answer 401 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;
  • register for a session that already points at an instance whose retained standing is active and whose window has not passed, unless --replace-session is given. With the flag the new record keeps the earlier instance as session_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.

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_mediated and crossing_refused: policy_identity is 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_identity is the digest of the policy a mediated crossing in the boundary’s cwd would 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 the cwd the 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.

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.

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

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.

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.

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.

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.

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.

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

  • 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, .internal and file://. 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_prefixes in policy.json lists 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/a and https://rag.example.internal/notes%2Fa are under https://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 in context_status, so the console can say what is being recorded and on whose authority. The mediated tools honour the same prefixes, and allow_private_hosts is 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": true marker, 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 from attribution.json. A label written into evidence would have to be rewritten when a rule changes, and evidence is never rewritten. The cwd is 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.
Terminal window
commonmeasure session # the most recent session
commonmeasure session <id> # a named one
commonmeasure session <id> --json
commonmeasure session <id> --since 2026-10-09T10:00:00Z --json
commonmeasure serve # the session view of the console, on loopback

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

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

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.

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