What these things are not
Status: explanation. Each entry corrects a reading that is natural, common, and wrong. Audience: anyone forming a mental model of Nightjar from its API surface. Scope: category corrections — where a name suggests one kind of thing and the system means another.
Every correction here exists because the obvious reading is reasonable. These are not gotchas; they are places where a short, honest name is ambiguous, and where acting on the wrong reading costs you something real.
A content tag is a correlation token, not a hash of the content
Reads that stay value-free return a contentTag alongside a length. It lets you tell “the same
thing I saw before” from “something changed”.
It is not a digest you can compute offline. You cannot hash a guess and compare, and the same text read in a different slot or a different session does not produce the same tag.
Why the distinction matters: if it were a content hash, it would be a value — an oracle you could brute-force against short or low-entropy content. A correlation token answers the comparison question without answering the content question.
A caller-supplied org is not an unverified org
It is tempting to state the invariant as “the organisation is never caller-supplied”. That is stronger than the truth, and the true form is the one that actually protects you:
The organisation IS caller-supplied — and it is verified against the session’s memberships.
Why the weaker-sounding version is the correct one: “never caller-supplied” is a claim about where a value came from, which nothing can check downstream. “Verified against this session’s memberships” is a claim about what was done with it, which is testable and tested. An on-page organisation picker routed through the same verification is therefore the same trust model, not a new one.
A reference is not encryption, and value-free is not redaction
An artifact reference keeps content out of your agent’s transcript. It does not transform the content, restrict who may later fetch it, or remove anything sensitive from it.
Concretely: if a page displays a secret and you extract that page, the artifact contains the secret. The reference changes who has it in their context by default, which is a real and useful property — and it is not confidentiality.
The failure this prevents: an agent quoting its own context onward. The failure it does not prevent: deciding to fetch and publish the artifact anyway.
A screenshot is not a value-free surface
Value-free reads exist so content does not enter an agent’s context. A screenshot is a picture of whatever is on screen — including a credential the platform just typed into a visible field.
Practical consequence: filling a masked field keeps the value off-screen; filling a plain text field does not. If a flow needs both a secret and a screenshot, the field type decides whether those two facts stay separate.
Field-level cases, from the specification
Each section below is moved verbatim from the OpenAPI description it used to live in, and the schema keeps a one-line pointer where it was. They are grouped by the shape of the misreading they prevent — a different axis from the arguments above, which is why they sit together rather than being folded into them.
Absence means something specific
The largest group, and this page’s thesis at field level: an omitted field is a claim, not a gap. Reading it as “unknown” or as zero loses the distinction the field exists to carry.
ArtifactProvenance.processing
⚠ OMITTED IS NOT [ArtifactProcessing::Unknown], and the difference is real. An explicit unknown means the mint site considered the axis and could not establish it. An OMITTED
field means no declaration exists at all — every artifact minted before #3018 shipped, whose
journal event was written when the field did not exist. A consumer distinguishing “the node
says it does not know” from “nobody ever said” needs both, which is why this is optional
rather than defaulted.
⇒ direction: derived is the LEGACY spelling of processing: derived with the source axis
unknown. It stays on the wire and is deprecated in docs only — 0.1.3 is published and
pinnable, so re-cutting it would cost a migration for no gain.
CommandProvenance.direction
⛔ OMITTED when [Self::artifacts] is empty, and that is a fidelity choice rather than a
tidiness one: an occurrence that minted nothing has no direction, and reporting
[ArtifactDirection::Unknown] there would say “we could not classify it” about a question
that does not arise. The oracle can always answer it because it is keyed BY an artifact; this
surface is not, so the field is genuinely optional here and is typed that way.
CommandProvenance.pageUrl
⛔ Read from the artifact ref’s own pageUrl, NEVER from the event’s kind.url — that field
carries its query string and the journal read strips it to host+path under #1076. So an
occurrence that minted nothing omits this, which is honest: the field describes a mint.
HarEntry._tabId
⚠ ABSENT means UNATTRIBUTED, not “some other tab”. A request is attributed from whichever
captured phase named a tab; if none did, this is omitted. A ?tabId= filter therefore EXCLUDES
unattributed entries rather than passing them — an entry that never named a tab is not evidence
about the tab you asked for. Read an unfiltered document if you need those.
JournalEvent.tabId
⛔ Ships explicitly OPTIONAL, and MUST stay that way — no default, no empty string, ever. Absence here is REAL and KNOWN, not a gap to paper over: the sibling /console projection
documents that entries with no _tabId are EXCLUDED, not passed, and this field is lifted
from the same captured payload. Publishing a placeholder would claim an attribution the node
never made, which is the only-publish-what-is-honoured rule this repo already applies to
hints — a consumer joining on a fabricated tab id would silently mis-group a timeline.
UsageSummary
browserMinutes + sessions are REAL (derived from the org’s sessions rows this period). tunnelGb, estCostUsd, and tunnelP50Ms are Option<f64> so the wire distinguishes “not yet
metered” (null) from a true zero: the control plane does not observe node-side tunnel bytes /
latency, and pricing is deferred — those are reported as null, never fabricated as 0. (See
ADR-016 on the optional/nullable distinction; required = true keeps the key present-but-null.)
Session.restoreOutcome
ALWAYS PRESENT as RestoreOutcome | null on create + later GET (never omitted). Hence no skip_serializing_if and nullable = true.
ArtifactProvenanceBatchResponse
⭐ THE CONTRACT: N distinct hashes in, N answers out. ALWAYS. An unknown hash is a RESULT
carrying an empty provenance, never an error and never an omitted entry. This is the consumer’s
load-bearing requirement and it is a contract decision rather than an ergonomic one — in their
words, “the whole reason I called content-addressing an oracle is that ‘this store has never seen
these bytes’ is a total, read-scope-free check; a 404 per unknown hash inside a batch destroys
that.” A batch of 26 where 3 are unknown returns 26 answers, three of them empty.
⇒ So the endpoint answers 200 with empty sets, and reserves non-200 for facts about the REQUEST (malformed hash, empty list, over the cap) — never about what storage does or does not hold.
⛔ Each entry’s empty-set semantics are exactly [ArtifactProvenanceResponse]’s — reused
verbatim rather than re-described, so the three causes of an empty answer (two deliberately
indistinguishable security readings, plus the scan-bound completeness limit that is NOT covered by
that guarantee) cannot drift between the single and batch forms.
A signal says less than it appears to
Each of these is true and narrower than it looks. The failure is not disbelieving them — it is reading a second, stronger claim out of them.
ActReceipt.tabGenerationBefore
⚠ #2417 — this pair tracks COMMAND generations, not navigation. The underlying counter is
bumped on navigation, context reset, or any event that invalidates page content, and goto bumps it unconditionally — so on that verb the pair moves by exactly one whether or not
anything navigated. Read it as “page content may have changed, re-perceive”, never as “a
navigation occurred”.
For the navigation question use [GotoResult::did_navigate] (nav.didNavigate on an act(goto) result), which is derived from the CDP Page.frameNavigated counter plus a
same-document URL signal.
RULED 2026-08-11 by CAA: the pair stays as-is. Re-pointing it at the nav counter would make one wire field mean different things per verb — a worse coherence defect than the one #2417 filed. If an audit-path consumer ever needs navigation truth on the receipt, the remedy is ADDITIVE.
ApiKeyCreated.templateNotAppliedReason
⚠ A key that received no templated scope is NOT powerless: a zero-scope agent-bound key still AUTHENTICATES, and still resolves to its agent on the identity-auth routes. Read this as “no templated capability”, never as “an inert credential”.
Daemon
⇒ So a consumer MAY branch on this field, and must know it is a CLAIM rather than a verified fact. Its
siblings are not the same kind of thing: connectedSince and reachable are node-derived, and it is
exactly that mixture which makes an undeclared version misleading rather than merely thin.
⇒ Deliberately NOT validated: nothing internal branches on it, so a version gate would be friction with no safety. The remedy for an unverified value that reaches consumers is to DECLARE it, not to refuse it.
SecurityLogEntry
api_key_fingerprint — the row’s org_id is NULL (a pre-auth writer) and it was matched because
its detail carries the sha256 fingerprint of one of this org’s live API keys. Strong, but it is
an inference from a hash match rather than a recorded association.
V1Error.retriable
Expect retriable: true beside retryHint: NEVER, routinely, with neither being wrong. The bool is derived per ERROR VARIANT in the control plane; the hint is forwarded PER SITE
from the node. They are computed independently, so a timeout whose verbatim resubmit could succeed (retriable: true) can also be one no automated client should repeat
(retryHint: NEVER — e.g. resubmitting would re-deliver a side effect that already landed). This pairing is a standing shape of the wire, not an anomaly to report.
ProjectionSearchOutcome
⭐ Collapsing a containment chain is NOT layout inference: containment is a lattice property of the tree, exact and decidable, and none of layout, ordering or sibling index is consulted.
Positions are child-index paths from the document element — integers only, never page content.
Two tokens in one name
The same field name carries different value spaces on different paths. Reading one as the other yields a valid-looking identifier that joins to nothing — never a parse error.
ActReceipt.requestId
⚠ This is the RECEIPT’s own id and is NOT the key that network traffic is stamped with. For “which requests did this act cause?” use [ActReceipt::command_span_id] below — the two
are different identifier spaces, and reading one as the other yields a valid-looking id that
joins to nothing (#2861).
V1Error.code
⚠ So code is the RENDER token here and the SEMANTIC token on both other paths. The same
missing element is code: "E_ELEM_NF" on /v1, and error.code: "NO_SUCH_ELEMENT" beside error.render_code: "E_ELEM_NF" on the node envelope. Reading one path’s code as if it were
the other’s yields a valid-looking token from the wrong value space, never a parse error — so
a client that reads both paths must select the field per path rather than by name.
/v1 publishes the render token only: there is no renderCode on this contract, and no
semantic-token field. A client on /v1 alone needs no second spelling.
The two tokens are paired once, in core/manifest/semantic/errors/runtime.yaml (semantic_code + render_code per entry) and generated from there, so the mapping is
one-to-one and stable rather than per-site.
HarEntry._seq
⛔ It is the MAX over the entry’s events, not the request event’s seq, and that is the whole
design. An entry ACCUMULATES — networkRequestStarted → …ResponseReceived → …ResponseBodyCaptured → …RequestCompleted — so a polling client must see the entry AGAIN each
time it gains a phase. Keying on the request’s seq would emit each entry exactly once and then
never show its response, which is the failure mode the cursor exists to prevent.
⚠ Consequence, stated because a caller will otherwise treat it as a bug: the same connection (requestId) can legitimately appear across successive polls with a higher _seq and more fields filled in. Dedupe on connection, not on the pair.
Without this field the cursor would be write-only: a caller could pass after_seq but had
no way to learn what to pass next. console_log already exposes its per-entry seq for exactly
this reason, so the shape is the surface’s existing precedent rather than a new convention.
PrincipalTier
Deliberately BINARY: it mirrors exactly the one distinction the human-tier gates make
(human vs everything-else), and is NOT a projection of the finer principal taxonomy. An
agent-bound key and a key_handle/service key both report key, because the gates they
hit do not distinguish them either — all are refused identically. Widening this to expose agent_template/agent_instance would disclose
more than any affordance needs — don’t. Subdividing the non-human space would also turn pure
self-reflection into a delegation oracle (leaking which kind of delegated thing you are —
structure the read gate deliberately does not expose), so every non-human principal
serializes as key, full stop.
NOT the same thing as the Principal.kind field on the identity-administration surface, which is
a free-form string over the FULL taxonomy (human | agent_template | agent_instance | service | key_handle). This type is deliberately coarser: it encodes the TIER DECISION, not the taxonomy.
Do not map one onto the other.
CONSUMER CONTRACT — fail CLOSED. Treat this as human-vs-not-human, where human is the ONLY
value that may enable a human-tier affordance: any other value, an unrecognized value, or an
ABSENT field means NOT human. The set is closed at two today, but should it ever widen by a
deliberate ruling, an older client must degrade to hiding a forbidden affordance — never to
enabling one.
A taxonomy asking two questions
One field, two axes. Nothing can be classified until you decide which question is being answered.
ArtifactDirection
⛔ Derived IS NEVER PRODUCED, AND THAT IS A FINDING RATHER THAN AN OVERSIGHT. It comes from the
CONSUMER’s taxonomy, and their taxonomy asks two different questions in one enum:
UploadedByCaller ┐ answer WHO SUPPLIED the bytes — a SOURCE question
CapturedFromPage ┘
Derived answers HOW MUCH THE NODE PROCESSED — a MANUFACTURE question Those are not mutually exclusive, so no artifact can be classified without first choosing which
question is being answered. extract is the proof: ONE verb with TWO implementations
(materialize_extracted_text_impl, the page’s own text; materialize_extracted_structured_impl,
which takes a node-constructed StructuredNode tree the page never held). Both are page-SOURCED;
only the second is node-MANUFACTURED. This enum reports the SOURCE axis, so both map to
[ArtifactDirection::CapturedFromPage].
⇒ The variant is kept rather than dropped because it is the consumer’s word, and reporting that our system cannot produce it is information. If they want the manufacture question too, it is a SECOND field — never a third value on this one.
CaptureAuthStateRequest.requiredStorageRead
⛔ SEPARATE from required_completeness, never folded into it. completeness is derived
from what was CAPTURED and is an ORDERING; a read failure is a confidence qualifier ON that
measurement, not a level OF it. A multi-origin capture where one origin succeeds and others
refuse yields cookies_and_storage WITH storage_read=failed — which SATISFIED a requiredCompleteness bar and was persisted as latest. That is the defect this closes, and
it is instantiated in production.
Opt-in and additive: absent ⇒ no precondition, byte-identical to today. Existing callers stay exposed until they pass this field — a deliberate second step, not an oversight.
⛔ THE GUARANTEE IS PER-CALL AND DOES NOT PROPAGATE (#2053). Passing this on capture says
nothing about a later promote, which is a separate endpoint with its own precondition — so a
caller who sets the bar HERE and omits it there can still promote a partial version to latest.
That residual is accepted rather than overlooked: carrying the intent between calls would mean
an opt-in precondition the caller cannot opt OUT of, which is not opt-in. Set it on both calls
if you want the guarantee on both.
ClipboardCopyOutcome
⛔ This was a String holding a Rust Debug dump until #2357’s follow-up, and that is not a
formatting complaint — it defeated the verb. clipboard_capture is BYTE-FREE by design: it
returns a session://artifacts/<hash> ref precisely so the captured content never enters the
caller’s context. Measured by the consumer on a real capture:
total response 106 643 bytes
captured content 31 976 bytes <- deliberately WITHHELD, returned only as a ref
copyOutcome ~74 000 bytes <- a Debug dump of the node struct, inside a JSON string
⇒ 3.3x the size of the content the verb refused to inline, in every response, forever. The bulk was target (window/tab/frame ids) and requestedLocator (an echo of the caller’s own
input) — two nested structs, neither carrying a decision. What survives here is what a caller
BRANCHES on. A caller who genuinely needs the target is better served by a named field than by
text inside a string, and none has asked.
⚠ Replaced in ONE step rather than expand/contract. That rule optimises AVAILABILITY during a rollout, and the defect here is SIZE: dual-emitting would preserve the whole 74 KB for the entire compat window, so the overlap IS the harm — the same reasoning that denies a deprecation window to a field being removed because it leaks.
ArtifactProvenanceBatchRequest.hashes
⚠ DUPLICATES COLLAPSE. The answer is keyed by hash, so asking twice about the same hash
yields ONE entry for it — the response is one entry per DISTINCT hash, never per request slot.
A client correlating by position must de-duplicate first; one correlating by hash (the
intended use, and why the single form echoes it too) needs no change.
⛔ A malformed hash is a 400 for the WHOLE request, and that is deliberate: it is decidable
from the string without touching storage, so rejecting it discloses nothing, and accepting a
batch while silently dropping one member would make the answer set differ from the ask set —
exactly the property [ArtifactProvenanceBatchResponse] guarantees against.
How to read the rest of the reference with this in mind
The API reference is where those descriptions live. When a description says a thing is “not” something, that sentence is usually load-bearing and was written because somebody read it the other way first. It is worth more attention than the sentence that says what the thing is.