What Nightjar refuses, and why

Status: explanation. Every refusal below is enforced in shipped code, not planned. Audience: a technically sophisticated evaluator deciding whether to trust this with credentials and network reach. Scope: the deliberate nos — what the platform will not do, and the reasoning that makes each one a design choice rather than a missing feature.

A product’s capabilities tell you what it can do for you. Its refusals tell you what it can do to you — and that is the question you are actually asking. This page collects the second kind.


The shape of a refusal here

Most refusals in Nightjar are structural: the unwanted thing is not blocked by a check that could be misconfigured, it is unrepresentable. Where a refusal is a check, we say so, because the two give you very different guarantees.

A control you can turn off is a policy. A shape that cannot express the failure is a guarantee. Both appear below, labelled.


Your content is returned by reference, not by value

Tools that touch page content — extraction, clipboard, downloads — hand your agent a session://artifacts/<hash> reference rather than the bytes. The content is materialised on the node and never crosses the tool boundary inline.

Why: an agent’s context is a transcript. Anything returned by value is durable, quotable, and copied onward by default. A reference makes the decision to look explicit and separately governed.

What this does NOT mean: it is not encryption, and it is not redaction. The artifact holds whatever the page held. See What these things are not.


An org you are not a member of is indistinguishable from one that does not exist

When a request names an organisation, the platform resolves it against the session’s memberships. A non-member org returns exactly what an unknown org returns.

Why: the alternative leaks. If “exists but you lack access” were distinguishable from “no such org”, the error itself becomes a directory of every tenant on the platform, readable by anyone with an account.

How it is enforced: by a named test, so a future change that reintroduces the distinction fails the build rather than shipping quietly.


The platform refuses to guess which organisation you meant

A human who belongs to several organisations, arriving without naming one, is not defaulted to the first, the newest, or the largest. The request is refused as ambiguous.

Why: silently choosing an org means silently choosing a blast radius. A wrong guess is not a usability annoyance — it is work performed against the wrong tenant.

The cost, honestly: a multi-org human sees a prompt where a single-org human sees none. We took the prompt.


Secrets are filled, never handed over

A stored credential is typed into a page by the node and zeroised. It is never returned to the agent that asked for it, never written to the session journal, and never appears in a screenshot the agent requested.

Why: an agent that never receives a secret cannot leak one — through a transcript, a log, a bug report, or a model that quotes its own context back. The value-free boundary is what makes “an agent drives your browser” a bounded claim instead of an unbounded one.


Field-level refusals, 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 what makes the refusal hold — which is a different question from the arguments above, and the reason they sit together rather than being folded into them.

Refusals that are structural, not policy

The thing cannot be formed, so there is nothing to enforce. This is the largest group here, and it is the opening argument of this page stated at field level.

ExtractRequest

mode is a CLOSED enum ([ExtractMode]) — text (default) or structured (#1333). The node-side raw-markup html mode remains unrepresentable here (see [ExtractMode]); together with #[serde(deny_unknown_fields)] a stray field or a non-variant mode is a hard 400 rather than a silently-ignored input — a future refactor cannot quietly re-widen the read past drive (pinned by negative tests in the exact-set suite).

ProjectionSearchRequest

Whole-page by construction: there is deliberately NO selector, because scoping the search to one element would presuppose the answer it exists to find.

Gated on its OWN projection_search operation rather than folded into extract (CAA ruling, #2962): a dominance argument for extract was proposed and ruled unproven, because reproducing this search client-side needs the PER-ELEMENT innerText multiset and extract yields either flat text or a closed block enum that collapses exactly the wrapper elements it ranges over.

StreamTicket

The ticket is bound to its own session AND its own stream kind — each mint writes into a dedicated store, so a viewport ticket is structurally absent from the journal store and can never be replayed to open a different stream. Authority is checked at mint time, on the authenticated POST; the handshake itself reads no Authorization header (see the streamTicket security scheme).

BOTH FIELDS ARE SECRET-BEARING. See each field — this is not a struct-level caveat you can discharge by redacting one of them.

CredentialFillRequest

The model passes ONLY the credential SLOT name plus the target selector; there is NO text/value field by construction, so the model structurally cannot place the secret. Control JIT-decrypts the named credential on the session’s bound identity into a Zeroizing buffer and forwards a node type act (selector, origin allowlist, requireEditableField=true); the node verifies live origin + editable-field before typing, then zeroizes. The fill act itself is value-free: the secret is never returned in the result, journaled, screenshotted, or audited. Filling a non-masked (text/email) field leaves the value visible on the rendered page, where later perceives/screenshots can observe it — prefer masked (password) fields. The response reuses [ActResult].

/v1/reach/occupancy/get

Discloses no more than a bind already does: reach/ensure’s contention refusal already returns this exact holder to any org member who contends for the target, so this removes the side effect, not a disclosure boundary. Org-anchored by construction (org_id predicate), so a cross-org answer cannot be formed. Same reach:self entitlement as ensure: if you may take a target, you may ask whether it is taken. /v1 only — deliberately NOT projected as an agent MCP tool (no consumer asked for one, and adding one later is additive while removing one is breaking; un-gate is the first agent consumer that asks).

Refusals that disclose nothing new

The answer was already reachable, so withholding it would buy nothing. Each is decided from state the caller already has.

AccessRequestCreated

⚠ THE ECHO DOES NOT WEAKEN THE ORACLE PROPERTY, and that is the whole reason it is safe (#2686 remedy 2). requestedScopes and delivery are the AGENT’S OWN INPUTS read back — never server-resolved state. The agent learns nothing it did not send; what it gains is the ability to verify BY EFFECT that the shape it sent is the shape that landed. requestedIdentityRef is deliberately NOT echoed: it is also agent-supplied, but echoing it invites a reader to treat this response as confirmation the identity resolved, which it never is.

ReachRefusalState

These are ordered cheapest-to-decide, and each is decided from state the caller already has access to — never from request input, so none of them is an oracle:

CredentialNotAgentBound  ctx.agent_id.is_none()          no DB read at all
OrgFlagDisabled          governance_settings             the read that was already here
CredentialNotReissued    EXISTS(reach:self key for me)   the added read
BindingPresentsOldKey    otherwise

What a refusal attests, and what it does not

A refusal carries exactly one claim. Reading a second one out of it is where callers go wrong.

ReachRefusal

R-d: this is DATA IN THE BODY. The approval URL is returned as a field for the caller to PRINT — never auto-opened, never written to stdout by the platform.

R-a: approvalUrl NAMES a request; it never encodes a delta. The id is the whole payload, so the URL cannot be edited into a different grant, and what the operator approves is looked up server-side rather than read off the link.

Shape follows [ReachRemedy] deliberately — the house pattern for handing a caller one unambiguous next action, with a list of outstanding preconditions rather than a single sentence.

RecordingStartResult

This verb answered {} until #2419: a deliberate value-free ack. The gap that closed is narrow and worth stating exactly, because the issue rejected three receipt-shaped alternatives for carrying zero bitsoutcome_certainty is always COMPLETED on a success-only response, a timestamp attests when the ack was written, and a request_id is a correlation handle.

fps is none of those: the node clamps to 1..=30 (default 4) and spawns the encoder with the clamped value, so it is observed node-side policy the caller cannot compute — the request carries fps as input only and the bounds appear nowhere on the wire. It therefore VARIES between two SUCCESSFUL starts, which is precisely what a zero-bit field cannot do.

It does not attest that frames are flowing. Presence-on-success carries no information (every field of a success-only response shares that property); the content is the rate capture was actually configured at. For “did it capture anything?“, recording-stop reports frameCount.

Metadata only, never a frame — the ADR-046 authority floor is unchanged.

The gate is authority, never a content filter

A scope decides who may read; it has never decided what the bytes are.

ConsoleEntry.text

⛔ #2238 removed the secret-shape floor that once redacted tokens here (operator ruling: no heuristics), so this exit performs no content filtering. The sessions:read org scope is the gate, and it was never a content filter.

What still holds is the OVERSIZE branch, and it is structural rather than a filter: a message too large to have been inlined (persisted as an artifact or chunk-series ref) surfaces a placeholder rather than its bytes — those bytes are never fetched on this path at all, which is the objection that survived #2238 (it was never about flooring, it was about not opening a second exit).

What is not on this page

Refusals that exist only as configuration — things an operator can switch on or off — are documented with their surface rather than here, because they are policy and this page is about shape. If you need to know whether a specific control is armed in your deployment, the API reference and build status pages answer that; this page cannot, and would be lying if it tried.