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.